From 1392bd3a96045302b60d845a90901a4b2234c475 Mon Sep 17 00:00:00 2001 From: zino Date: Wed, 20 Jan 2021 12:59:59 +0100 Subject: [PATCH] Squashed 'seatmap-webapi/' content from commit 02d4bf7 git-subtree-dir: seatmap-webapi git-subtree-split: 02d4bf7404b8fcb788502ca45c813946b6c4f5b9 --- .gitignore | 3 + .htaccess | 5 + CONTRIBUTING.md | 21 + Dockerfile | 15 + LICENSE | 21 + README.md | 1389 ++ api.include.php | 11387 +++++++++++++++ api.php | 11410 ++++++++++++++++ build.php | 113 + composer.json | 54 + composer.lock | 396 + docker-compose.yml | 31 + docker/build_all.sh | 10 + docker/centos8/Dockerfile | 36 + docker/centos8/run.sh | 60 + docker/clean_all.sh | 5 + docker/debian10/Dockerfile | 16 + docker/debian10/run.sh | 58 + docker/debian9/Dockerfile | 16 + docker/debian9/run.sh | 58 + docker/run.sh | 21 + docker/run_all.sh | 12 + docker/ubuntu16/Dockerfile | 37 + docker/ubuntu16/run.sh | 73 + docker/ubuntu18/Dockerfile | 21 + docker/ubuntu18/run.sh | 60 + docker/ubuntu20/Dockerfile | 21 + docker/ubuntu20/run.sh | 60 + examples/clients/angular.html | 24 + examples/clients/angular2.html | 31 + examples/clients/auth.php/vanilla.html | 33 + examples/clients/auth0/vanilla.html | 49 + examples/clients/datatables.html | 45 + .../clients/firebase/vanilla-success.html | 97 + examples/clients/firebase/vanilla.html | 68 + examples/clients/handlebars.html | 66 + examples/clients/knockout.html | 70 + examples/clients/leaflet/geojson-layer.js | 85 + .../clients/leaflet/geojson-tile-layer.js | 144 + examples/clients/leaflet/vanilla.html | 37 + examples/clients/mustache.html | 66 + examples/clients/qgis/geojson.png | Bin 0 -> 222426 bytes examples/clients/react.html | 47 + examples/clients/upload/vanilla.html | 73 + examples/clients/vanilla.html | 20 + examples/clients/vue.html | 250 + examples/clients/zepto.html | 76 + examples/index.html | 22 + extras/core.php | 66 + install.php | 11 + patch.php | 51 + src/Tqdev/PhpCrudApi/Api.php | 215 + src/Tqdev/PhpCrudApi/Cache/Cache.php | 10 + src/Tqdev/PhpCrudApi/Cache/CacheFactory.php | 27 + src/Tqdev/PhpCrudApi/Cache/MemcacheCache.php | 45 + src/Tqdev/PhpCrudApi/Cache/MemcachedCache.php | 16 + src/Tqdev/PhpCrudApi/Cache/NoCache.php | 25 + src/Tqdev/PhpCrudApi/Cache/RedisCache.php | 38 + src/Tqdev/PhpCrudApi/Cache/TempFileCache.php | 149 + .../PhpCrudApi/Column/DefinitionService.php | 159 + .../Column/Reflection/ReflectedColumn.php | 213 + .../Column/Reflection/ReflectedDatabase.php | 71 + .../Column/Reflection/ReflectedTable.php | 169 + .../PhpCrudApi/Column/ReflectionService.php | 103 + src/Tqdev/PhpCrudApi/Config.php | 205 + .../PhpCrudApi/Controller/CacheController.php | 26 + .../Controller/ColumnController.php | 162 + .../Controller/GeoJsonController.php | 61 + .../PhpCrudApi/Controller/JsonResponder.php | 24 + .../Controller/OpenApiController.php | 26 + .../Controller/RecordController.php | 176 + src/Tqdev/PhpCrudApi/Controller/Responder.php | 12 + .../PhpCrudApi/Database/ColumnConverter.php | 73 + .../PhpCrudApi/Database/ColumnsBuilder.php | 119 + .../PhpCrudApi/Database/ConditionsBuilder.php | 217 + .../PhpCrudApi/Database/DataConverter.php | 104 + src/Tqdev/PhpCrudApi/Database/GenericDB.php | 340 + .../PhpCrudApi/Database/GenericDefinition.php | 446 + .../PhpCrudApi/Database/GenericReflection.php | 206 + src/Tqdev/PhpCrudApi/Database/LazyPdo.php | 124 + .../PhpCrudApi/Database/TypeConverter.php | 219 + src/Tqdev/PhpCrudApi/GeoJson/Feature.php | 32 + .../PhpCrudApi/GeoJson/FeatureCollection.php | 32 + .../PhpCrudApi/GeoJson/GeoJsonService.php | 127 + src/Tqdev/PhpCrudApi/GeoJson/Geometry.php | 64 + .../Middleware/AjaxOnlyMiddleware.php | 28 + .../Middleware/AuthorizationMiddleware.php | 103 + .../PhpCrudApi/Middleware/Base/Middleware.php | 46 + .../Middleware/BasicAuthMiddleware.php | 118 + .../Communication/VariableStore.php | 21 + .../PhpCrudApi/Middleware/CorsMiddleware.php | 94 + .../Middleware/CustomizationMiddleware.php | 42 + .../Middleware/DbAuthMiddleware.php | 152 + .../Middleware/FirewallMiddleware.php | 57 + .../Middleware/IpAddressMiddleware.php | 68 + .../Middleware/JoinLimitsMiddleware.php | 56 + .../Middleware/JwtAuthMiddleware.php | 156 + .../Middleware/MultiTenancyMiddleware.php | 98 + .../Middleware/PageLimitsMiddleware.php | 51 + .../Middleware/ReconnectMiddleware.php | 103 + .../PhpCrudApi/Middleware/Router/Router.php | 17 + .../Middleware/Router/SimpleRouter.php | 164 + .../Middleware/SanitationMiddleware.php | 150 + .../Middleware/SslRedirectMiddleware.php | 27 + .../Middleware/ValidationMiddleware.php | 217 + .../PhpCrudApi/Middleware/XmlMiddleware.php | 155 + .../PhpCrudApi/Middleware/XsrfMiddleware.php | 42 + .../PhpCrudApi/OpenApi/OpenApiBuilder.php | 53 + .../OpenApi/OpenApiColumnsBuilder.php | 193 + .../PhpCrudApi/OpenApi/OpenApiDefinition.php | 46 + .../OpenApi/OpenApiRecordsBuilder.php | 374 + .../PhpCrudApi/OpenApi/OpenApiService.php | 21 + .../PhpCrudApi/Record/ColumnIncluder.php | 71 + .../Record/Condition/AndCondition.php | 36 + .../Record/Condition/ColumnCondition.php | 34 + .../PhpCrudApi/Record/Condition/Condition.php | 68 + .../Record/Condition/NoCondition.php | 21 + .../Record/Condition/NotCondition.php | 18 + .../Record/Condition/OrCondition.php | 36 + .../Record/Condition/SpatialCondition.php | 7 + .../Record/Document/ErrorDocument.php | 43 + .../Record/Document/ListDocument.php | 41 + src/Tqdev/PhpCrudApi/Record/ErrorCode.php | 87 + src/Tqdev/PhpCrudApi/Record/FilterInfo.php | 47 + src/Tqdev/PhpCrudApi/Record/HabtmValues.php | 15 + src/Tqdev/PhpCrudApi/Record/OrderingInfo.php | 47 + .../PhpCrudApi/Record/PaginationInfo.php | 69 + src/Tqdev/PhpCrudApi/Record/PathTree.php | 80 + src/Tqdev/PhpCrudApi/Record/RecordService.php | 123 + .../PhpCrudApi/Record/RelationJoiner.php | 296 + src/Tqdev/PhpCrudApi/RequestFactory.php | 43 + src/Tqdev/PhpCrudApi/RequestUtils.php | 100 + src/Tqdev/PhpCrudApi/ResponseFactory.php | 59 + src/Tqdev/PhpCrudApi/ResponseUtils.php | 50 + src/index.php | 24 + test.php | 202 + tests/config/.htpasswd | 1 + tests/config/base.php | 61 + tests/config/mysql.php | 2 + tests/config/pgsql.php | 3 + tests/config/sqlite.php | 2 + tests/config/sqlsrv.php | 2 + tests/fixtures/blog.sqlite | Bin 0 -> 106496 bytes tests/fixtures/blog_mysql.sql | 200 + tests/fixtures/blog_pgsql.sql | 548 + tests/fixtures/blog_sqlite.sql | 175 + tests/fixtures/blog_sqlsrv.sql | 442 + tests/fixtures/create_mysql.sql | 4 + tests/fixtures/create_pgsql.sql | 5 + tests/fixtures/create_sqlsrv.sql | 9 + .../functional/001_records/001_list_posts.log | 8 + .../001_records/002_list_post_columns.log | 8 + .../functional/001_records/003_read_post.log | 16 + .../functional/001_records/004_read_posts.log | 8 + .../001_records/005_read_post_columns.log | 8 + tests/functional/001_records/006_add_post.log | 11 + .../functional/001_records/007_edit_post.log | 18 + .../008_edit_post_columns_missing_field.log | 18 + .../009_edit_post_columns_extra_field.log | 18 + .../010_edit_post_with_utf8_content.log | 18 + ..._edit_post_with_utf8_content_with_post.log | 19 + .../001_records/012_delete_post.log | 16 + .../001_records/013_add_post_with_post.log | 10 + .../001_records/014_edit_post_with_post.log | 18 + .../015_delete_post_ignore_columns.log | 16 + .../001_records/016_list_with_paginate.log | 108 + .../001_records/017_edit_post_primary_key.log | 11 + .../018_add_post_missing_field.log | 11 + ...9_list_with_paginate_in_multiple_order.log | 8 + ...list_with_paginate_in_descending_order.log | 8 + .../001_records/021_list_with_size.log | 8 + .../022_list_with_zero_page_size.log | 8 + .../001_records/023_list_with_zero_size.log | 8 + .../024_list_with_paginate_last_page.log | 8 + ...5_list_example_from_readme_full_record.log | 8 + ..._list_example_from_readme_with_exclude.log | 8 + ...27_list_example_from_readme_users_only.log | 8 + ...28_read_example_from_readme_users_only.log | 8 + ...list_example_from_readme_comments_only.log | 8 + ...030_list_example_from_readme_tags_only.log | 8 + ...xample_from_readme_tags_with_join_path.log | 8 + .../032_list_example_from_readme.log | 8 + ...list_example_from_readme_tag_name_only.log | 8 + ...rom_readme_with_transform_with_exclude.log | 9 + .../035_edit_category_with_binary_content.log | 18 + .../036_edit_category_with_null.log | 36 + ...category_with_binary_content_with_post.log | 19 + ...38_list_categories_with_binary_content.log | 8 + .../039_edit_category_with_null_with_post.log | 19 + .../001_records/040_add_post_failure.log | 10 + .../001_records/041_cors_pre_flight.log | 12 + .../001_records/042_cors_headers.log | 11 + .../001_records/043_error_on_invalid_json.log | 11 + .../044_error_on_duplicate_unique_key.log | 11 + ...rror_on_failing_foreign_key_constraint.log | 10 + .../046_error_on_non_existing_table.log | 8 + .../001_records/047_error_on_invalid_path.log | 8 + .../048_error_on_invalid_argument_count.log | 10 + .../049_error_on_invalid_argument_count.log | 10 + .../050_no_error_on_argument_count_one.log | 10 + .../051_error_on_invalid_argument_count.log | 10 + .../001_records/052_edit_user_location.log | 18 + .../001_records/053_list_user_locations.log | 8 + .../001_records/054_edit_user_with_id.log | 18 + .../055_filter_category_on_null_icon.log | 16 + .../056_filter_category_on_not_null_icon.log | 8 + .../001_records/057_filter_on_and.log | 24 + .../001_records/058_filter_on_or.log | 8 + .../001_records/059_filter_on_and_plus_or.log | 8 + .../001_records/060_filter_on_or_plus_and.log | 8 + ...t_post_content_with_included_tag_names.log | 8 + .../001_records/062_read_kunsthandvaerk.log | 16 + .../001_records/063_list_kunsthandvaerk.log | 8 + .../001_records/064_add_kunsthandvaerk.log | 10 + .../001_records/065_edit_kunsthandvaerk.log | 10 + .../001_records/066_delete_kunsthandvaerk.log | 8 + .../067_edit_comment_with_validation.log | 10 + .../068_add_comment_with_sanitation.log | 18 + .../069_increment_event_visitors.log | 64 + .../001_records/070_list_invisibles.log | 8 + .../071_add_comment_with_invisible_record.log | 18 + .../functional/001_records/072_list_nopk.log | 8 + .../073_multi_tenancy_kunsthandvaerk.log | 52 + .../001_records/074_custom_kunsthandvaerk.log | 22 + .../001_records/075_list_tag_usage.log | 24 + ...76_list_user_locations_within_geometry.log | 9 + .../077_list_posts_with_page_limits.log | 32 + .../078_edit_event_with_nullable_bigint.log | 44 + .../079_read_post_with_categories.log | 32 + .../080_add_barcode_with_ip_address.log | 44 + .../081_read_countries_as_geojson.log | 73 + .../001_records/082_read_users_as_geojson.log | 17 + .../001_records/083_list_users_as_geojson.log | 41 + .../084_update_tags_with_boolean.log | 44 + ..._update_invisble_column_kunsthandvaerk.log | 44 + ...86_add_and_update_posts_in_large_batch.log | 23 + .../087_read_and_write_posts_as_xml.log | 116 + ...8_read_and_write_multiple_posts_as_xml.log | 26 + .../001_records/089_redirect_to_ssl.log | 5 + tests/functional/002_auth/001_jwt_auth.log | 44 + tests/functional/002_auth/002_basic_auth.log | 34 + tests/functional/002_auth/003_db_auth.log | 184 + .../003_columns/001_get_database.log | 9 + .../003_columns/002_get_barcodes_table.log | 8 + .../003_get_barcodes_id_column.log | 8 + .../004_update_barcodes_id_column.log | 37 + ...05_update_barcodes_product_id_nullable.log | 37 + .../006_update_events_visitors_pk.log | 81 + .../007_update_barcodes_product_id_fk.log | 37 + .../003_columns/008_update_barcodes_table.log | 36 + .../009_update_barcodes_hex_type.log | 37 + .../003_columns/010_create_barcodes_table.log | 26 + .../011_create_barcodes_column.log | 27 + .../003_columns/012_get_invisibles_table.log | 8 + .../003_columns/013_get_invisible_column.log | 8 + .../003_columns/014_create_types_table.log | 56 + .../003_columns/015_update_types_table.log | 337 + .../016_update_forgiving_table.log | 401 + .../017_get_barcodes_table_as_xml.log | 8 + .../functional/004_cache/001_clear_cache.log | 8 + update.php | 11 + 261 files changed, 39680 insertions(+) create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 api.include.php create mode 100644 api.php create mode 100644 build.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 docker-compose.yml create mode 100755 docker/build_all.sh create mode 100644 docker/centos8/Dockerfile create mode 100755 docker/centos8/run.sh create mode 100755 docker/clean_all.sh create mode 100644 docker/debian10/Dockerfile create mode 100755 docker/debian10/run.sh create mode 100644 docker/debian9/Dockerfile create mode 100755 docker/debian9/run.sh create mode 100755 docker/run.sh create mode 100755 docker/run_all.sh create mode 100644 docker/ubuntu16/Dockerfile create mode 100755 docker/ubuntu16/run.sh create mode 100644 docker/ubuntu18/Dockerfile create mode 100755 docker/ubuntu18/run.sh create mode 100644 docker/ubuntu20/Dockerfile create mode 100755 docker/ubuntu20/run.sh create mode 100644 examples/clients/angular.html create mode 100644 examples/clients/angular2.html create mode 100644 examples/clients/auth.php/vanilla.html create mode 100644 examples/clients/auth0/vanilla.html create mode 100644 examples/clients/datatables.html create mode 100644 examples/clients/firebase/vanilla-success.html create mode 100644 examples/clients/firebase/vanilla.html create mode 100644 examples/clients/handlebars.html create mode 100644 examples/clients/knockout.html create mode 100644 examples/clients/leaflet/geojson-layer.js create mode 100644 examples/clients/leaflet/geojson-tile-layer.js create mode 100644 examples/clients/leaflet/vanilla.html create mode 100644 examples/clients/mustache.html create mode 100644 examples/clients/qgis/geojson.png create mode 100644 examples/clients/react.html create mode 100644 examples/clients/upload/vanilla.html create mode 100644 examples/clients/vanilla.html create mode 100644 examples/clients/vue.html create mode 100644 examples/clients/zepto.html create mode 100644 examples/index.html create mode 100644 extras/core.php create mode 100644 install.php create mode 100644 patch.php create mode 100644 src/Tqdev/PhpCrudApi/Api.php create mode 100644 src/Tqdev/PhpCrudApi/Cache/Cache.php create mode 100644 src/Tqdev/PhpCrudApi/Cache/CacheFactory.php create mode 100644 src/Tqdev/PhpCrudApi/Cache/MemcacheCache.php create mode 100644 src/Tqdev/PhpCrudApi/Cache/MemcachedCache.php create mode 100644 src/Tqdev/PhpCrudApi/Cache/NoCache.php create mode 100644 src/Tqdev/PhpCrudApi/Cache/RedisCache.php create mode 100644 src/Tqdev/PhpCrudApi/Cache/TempFileCache.php create mode 100644 src/Tqdev/PhpCrudApi/Column/DefinitionService.php create mode 100644 src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedColumn.php create mode 100644 src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedDatabase.php create mode 100644 src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedTable.php create mode 100644 src/Tqdev/PhpCrudApi/Column/ReflectionService.php create mode 100644 src/Tqdev/PhpCrudApi/Config.php create mode 100644 src/Tqdev/PhpCrudApi/Controller/CacheController.php create mode 100644 src/Tqdev/PhpCrudApi/Controller/ColumnController.php create mode 100644 src/Tqdev/PhpCrudApi/Controller/GeoJsonController.php create mode 100644 src/Tqdev/PhpCrudApi/Controller/JsonResponder.php create mode 100644 src/Tqdev/PhpCrudApi/Controller/OpenApiController.php create mode 100644 src/Tqdev/PhpCrudApi/Controller/RecordController.php create mode 100644 src/Tqdev/PhpCrudApi/Controller/Responder.php create mode 100644 src/Tqdev/PhpCrudApi/Database/ColumnConverter.php create mode 100644 src/Tqdev/PhpCrudApi/Database/ColumnsBuilder.php create mode 100644 src/Tqdev/PhpCrudApi/Database/ConditionsBuilder.php create mode 100644 src/Tqdev/PhpCrudApi/Database/DataConverter.php create mode 100644 src/Tqdev/PhpCrudApi/Database/GenericDB.php create mode 100644 src/Tqdev/PhpCrudApi/Database/GenericDefinition.php create mode 100644 src/Tqdev/PhpCrudApi/Database/GenericReflection.php create mode 100644 src/Tqdev/PhpCrudApi/Database/LazyPdo.php create mode 100644 src/Tqdev/PhpCrudApi/Database/TypeConverter.php create mode 100644 src/Tqdev/PhpCrudApi/GeoJson/Feature.php create mode 100644 src/Tqdev/PhpCrudApi/GeoJson/FeatureCollection.php create mode 100644 src/Tqdev/PhpCrudApi/GeoJson/GeoJsonService.php create mode 100644 src/Tqdev/PhpCrudApi/GeoJson/Geometry.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/AjaxOnlyMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/AuthorizationMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/Base/Middleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/BasicAuthMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/Communication/VariableStore.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/CorsMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/CustomizationMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/DbAuthMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/FirewallMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/IpAddressMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/JoinLimitsMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/JwtAuthMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/MultiTenancyMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/PageLimitsMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/ReconnectMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/Router/Router.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/Router/SimpleRouter.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/SanitationMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/SslRedirectMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/ValidationMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/XmlMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/Middleware/XsrfMiddleware.php create mode 100644 src/Tqdev/PhpCrudApi/OpenApi/OpenApiBuilder.php create mode 100644 src/Tqdev/PhpCrudApi/OpenApi/OpenApiColumnsBuilder.php create mode 100644 src/Tqdev/PhpCrudApi/OpenApi/OpenApiDefinition.php create mode 100644 src/Tqdev/PhpCrudApi/OpenApi/OpenApiRecordsBuilder.php create mode 100644 src/Tqdev/PhpCrudApi/OpenApi/OpenApiService.php create mode 100644 src/Tqdev/PhpCrudApi/Record/ColumnIncluder.php create mode 100644 src/Tqdev/PhpCrudApi/Record/Condition/AndCondition.php create mode 100644 src/Tqdev/PhpCrudApi/Record/Condition/ColumnCondition.php create mode 100644 src/Tqdev/PhpCrudApi/Record/Condition/Condition.php create mode 100644 src/Tqdev/PhpCrudApi/Record/Condition/NoCondition.php create mode 100644 src/Tqdev/PhpCrudApi/Record/Condition/NotCondition.php create mode 100644 src/Tqdev/PhpCrudApi/Record/Condition/OrCondition.php create mode 100644 src/Tqdev/PhpCrudApi/Record/Condition/SpatialCondition.php create mode 100644 src/Tqdev/PhpCrudApi/Record/Document/ErrorDocument.php create mode 100644 src/Tqdev/PhpCrudApi/Record/Document/ListDocument.php create mode 100644 src/Tqdev/PhpCrudApi/Record/ErrorCode.php create mode 100644 src/Tqdev/PhpCrudApi/Record/FilterInfo.php create mode 100644 src/Tqdev/PhpCrudApi/Record/HabtmValues.php create mode 100644 src/Tqdev/PhpCrudApi/Record/OrderingInfo.php create mode 100644 src/Tqdev/PhpCrudApi/Record/PaginationInfo.php create mode 100644 src/Tqdev/PhpCrudApi/Record/PathTree.php create mode 100644 src/Tqdev/PhpCrudApi/Record/RecordService.php create mode 100644 src/Tqdev/PhpCrudApi/Record/RelationJoiner.php create mode 100644 src/Tqdev/PhpCrudApi/RequestFactory.php create mode 100644 src/Tqdev/PhpCrudApi/RequestUtils.php create mode 100644 src/Tqdev/PhpCrudApi/ResponseFactory.php create mode 100644 src/Tqdev/PhpCrudApi/ResponseUtils.php create mode 100644 src/index.php create mode 100644 test.php create mode 100644 tests/config/.htpasswd create mode 100644 tests/config/base.php create mode 100644 tests/config/mysql.php create mode 100644 tests/config/pgsql.php create mode 100644 tests/config/sqlite.php create mode 100644 tests/config/sqlsrv.php create mode 100644 tests/fixtures/blog.sqlite create mode 100644 tests/fixtures/blog_mysql.sql create mode 100644 tests/fixtures/blog_pgsql.sql create mode 100644 tests/fixtures/blog_sqlite.sql create mode 100644 tests/fixtures/blog_sqlsrv.sql create mode 100644 tests/fixtures/create_mysql.sql create mode 100644 tests/fixtures/create_pgsql.sql create mode 100644 tests/fixtures/create_sqlsrv.sql create mode 100644 tests/functional/001_records/001_list_posts.log create mode 100644 tests/functional/001_records/002_list_post_columns.log create mode 100644 tests/functional/001_records/003_read_post.log create mode 100644 tests/functional/001_records/004_read_posts.log create mode 100644 tests/functional/001_records/005_read_post_columns.log create mode 100644 tests/functional/001_records/006_add_post.log create mode 100644 tests/functional/001_records/007_edit_post.log create mode 100644 tests/functional/001_records/008_edit_post_columns_missing_field.log create mode 100644 tests/functional/001_records/009_edit_post_columns_extra_field.log create mode 100644 tests/functional/001_records/010_edit_post_with_utf8_content.log create mode 100644 tests/functional/001_records/011_edit_post_with_utf8_content_with_post.log create mode 100644 tests/functional/001_records/012_delete_post.log create mode 100644 tests/functional/001_records/013_add_post_with_post.log create mode 100644 tests/functional/001_records/014_edit_post_with_post.log create mode 100644 tests/functional/001_records/015_delete_post_ignore_columns.log create mode 100644 tests/functional/001_records/016_list_with_paginate.log create mode 100644 tests/functional/001_records/017_edit_post_primary_key.log create mode 100644 tests/functional/001_records/018_add_post_missing_field.log create mode 100644 tests/functional/001_records/019_list_with_paginate_in_multiple_order.log create mode 100644 tests/functional/001_records/020_list_with_paginate_in_descending_order.log create mode 100644 tests/functional/001_records/021_list_with_size.log create mode 100644 tests/functional/001_records/022_list_with_zero_page_size.log create mode 100644 tests/functional/001_records/023_list_with_zero_size.log create mode 100644 tests/functional/001_records/024_list_with_paginate_last_page.log create mode 100644 tests/functional/001_records/025_list_example_from_readme_full_record.log create mode 100644 tests/functional/001_records/026_list_example_from_readme_with_exclude.log create mode 100644 tests/functional/001_records/027_list_example_from_readme_users_only.log create mode 100644 tests/functional/001_records/028_read_example_from_readme_users_only.log create mode 100644 tests/functional/001_records/029_list_example_from_readme_comments_only.log create mode 100644 tests/functional/001_records/030_list_example_from_readme_tags_only.log create mode 100644 tests/functional/001_records/031_list_example_from_readme_tags_with_join_path.log create mode 100644 tests/functional/001_records/032_list_example_from_readme.log create mode 100644 tests/functional/001_records/033_list_example_from_readme_tag_name_only.log create mode 100644 tests/functional/001_records/034_list_example_from_readme_with_transform_with_exclude.log create mode 100644 tests/functional/001_records/035_edit_category_with_binary_content.log create mode 100644 tests/functional/001_records/036_edit_category_with_null.log create mode 100644 tests/functional/001_records/037_edit_category_with_binary_content_with_post.log create mode 100644 tests/functional/001_records/038_list_categories_with_binary_content.log create mode 100644 tests/functional/001_records/039_edit_category_with_null_with_post.log create mode 100644 tests/functional/001_records/040_add_post_failure.log create mode 100644 tests/functional/001_records/041_cors_pre_flight.log create mode 100644 tests/functional/001_records/042_cors_headers.log create mode 100644 tests/functional/001_records/043_error_on_invalid_json.log create mode 100644 tests/functional/001_records/044_error_on_duplicate_unique_key.log create mode 100644 tests/functional/001_records/045_error_on_failing_foreign_key_constraint.log create mode 100644 tests/functional/001_records/046_error_on_non_existing_table.log create mode 100644 tests/functional/001_records/047_error_on_invalid_path.log create mode 100644 tests/functional/001_records/048_error_on_invalid_argument_count.log create mode 100644 tests/functional/001_records/049_error_on_invalid_argument_count.log create mode 100644 tests/functional/001_records/050_no_error_on_argument_count_one.log create mode 100644 tests/functional/001_records/051_error_on_invalid_argument_count.log create mode 100644 tests/functional/001_records/052_edit_user_location.log create mode 100644 tests/functional/001_records/053_list_user_locations.log create mode 100644 tests/functional/001_records/054_edit_user_with_id.log create mode 100644 tests/functional/001_records/055_filter_category_on_null_icon.log create mode 100644 tests/functional/001_records/056_filter_category_on_not_null_icon.log create mode 100644 tests/functional/001_records/057_filter_on_and.log create mode 100644 tests/functional/001_records/058_filter_on_or.log create mode 100644 tests/functional/001_records/059_filter_on_and_plus_or.log create mode 100644 tests/functional/001_records/060_filter_on_or_plus_and.log create mode 100644 tests/functional/001_records/061_get_post_content_with_included_tag_names.log create mode 100644 tests/functional/001_records/062_read_kunsthandvaerk.log create mode 100644 tests/functional/001_records/063_list_kunsthandvaerk.log create mode 100644 tests/functional/001_records/064_add_kunsthandvaerk.log create mode 100644 tests/functional/001_records/065_edit_kunsthandvaerk.log create mode 100644 tests/functional/001_records/066_delete_kunsthandvaerk.log create mode 100644 tests/functional/001_records/067_edit_comment_with_validation.log create mode 100644 tests/functional/001_records/068_add_comment_with_sanitation.log create mode 100644 tests/functional/001_records/069_increment_event_visitors.log create mode 100644 tests/functional/001_records/070_list_invisibles.log create mode 100644 tests/functional/001_records/071_add_comment_with_invisible_record.log create mode 100644 tests/functional/001_records/072_list_nopk.log create mode 100644 tests/functional/001_records/073_multi_tenancy_kunsthandvaerk.log create mode 100644 tests/functional/001_records/074_custom_kunsthandvaerk.log create mode 100644 tests/functional/001_records/075_list_tag_usage.log create mode 100644 tests/functional/001_records/076_list_user_locations_within_geometry.log create mode 100644 tests/functional/001_records/077_list_posts_with_page_limits.log create mode 100644 tests/functional/001_records/078_edit_event_with_nullable_bigint.log create mode 100644 tests/functional/001_records/079_read_post_with_categories.log create mode 100644 tests/functional/001_records/080_add_barcode_with_ip_address.log create mode 100644 tests/functional/001_records/081_read_countries_as_geojson.log create mode 100644 tests/functional/001_records/082_read_users_as_geojson.log create mode 100644 tests/functional/001_records/083_list_users_as_geojson.log create mode 100644 tests/functional/001_records/084_update_tags_with_boolean.log create mode 100644 tests/functional/001_records/085_update_invisble_column_kunsthandvaerk.log create mode 100644 tests/functional/001_records/086_add_and_update_posts_in_large_batch.log create mode 100644 tests/functional/001_records/087_read_and_write_posts_as_xml.log create mode 100644 tests/functional/001_records/088_read_and_write_multiple_posts_as_xml.log create mode 100644 tests/functional/001_records/089_redirect_to_ssl.log create mode 100644 tests/functional/002_auth/001_jwt_auth.log create mode 100644 tests/functional/002_auth/002_basic_auth.log create mode 100644 tests/functional/002_auth/003_db_auth.log create mode 100644 tests/functional/003_columns/001_get_database.log create mode 100644 tests/functional/003_columns/002_get_barcodes_table.log create mode 100644 tests/functional/003_columns/003_get_barcodes_id_column.log create mode 100644 tests/functional/003_columns/004_update_barcodes_id_column.log create mode 100644 tests/functional/003_columns/005_update_barcodes_product_id_nullable.log create mode 100644 tests/functional/003_columns/006_update_events_visitors_pk.log create mode 100644 tests/functional/003_columns/007_update_barcodes_product_id_fk.log create mode 100644 tests/functional/003_columns/008_update_barcodes_table.log create mode 100644 tests/functional/003_columns/009_update_barcodes_hex_type.log create mode 100644 tests/functional/003_columns/010_create_barcodes_table.log create mode 100644 tests/functional/003_columns/011_create_barcodes_column.log create mode 100644 tests/functional/003_columns/012_get_invisibles_table.log create mode 100644 tests/functional/003_columns/013_get_invisible_column.log create mode 100644 tests/functional/003_columns/014_create_types_table.log create mode 100644 tests/functional/003_columns/015_update_types_table.log create mode 100644 tests/functional/003_columns/016_update_forgiving_table.log create mode 100644 tests/functional/003_columns/017_get_barcodes_table_as_xml.log create mode 100644 tests/functional/004_cache/001_clear_cache.log create mode 100644 update.php 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 0000000000000000000000000000000000000000..a5758966d7bccbccdba23928ebf83ec9991c1fb2 GIT binary patch literal 222426 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
CEJU`u)*4%pdVmqR6Cdz#jUal5qGsS<)~S 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 z

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 B*&zS` literal 0 HcmV?d00001 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 @@ + + + + + + + + +

+ + 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 @@ +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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/blog_mysql.sql b/tests/fixtures/blog_mysql.sql new file mode 100644 index 0000000..3caf94d --- /dev/null +++ b/tests/fixtures/blog_mysql.sql @@ -0,0 +1,200 @@ +-- Adminer 4.2.4 MySQL dump + +SET NAMES utf8; +SET time_zone = '+00:00'; +SET foreign_key_checks = 0; +SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'; + +DROP TABLE IF EXISTS `categories`; +CREATE TABLE `categories` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'The identifier of the category.', + `name` varchar(255) NOT NULL COMMENT 'The name of the category.', + `icon` blob COMMENT 'A small image representing the category.', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='The post categories of the blog system.'; + +INSERT INTO `categories` (`name`, `icon`) VALUES +('announcement', NULL), +('article', NULL), +('comment', NULL); + +DROP TABLE IF EXISTS `comments`; +CREATE TABLE `comments` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `post_id` int(11) NOT NULL, + `message` varchar(255) NOT NULL, + `category_id` int(11) NOT NULL, + PRIMARY KEY (`id`), + KEY `post_id` (`post_id`), + CONSTRAINT `comments_post_id_fkey` FOREIGN KEY (`post_id`) REFERENCES `posts` (`id`), + CONSTRAINT `comments_category_id_fkey` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `comments` (`post_id`, `message`, `category_id`) VALUES +(1, 'great', 3), +(1, 'fantastic', 3), +(2, 'thank you', 3), +(2, 'awesome', 3); + +DROP TABLE IF EXISTS `posts`; +CREATE TABLE `posts` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `category_id` int(11) NOT NULL, + `content` varchar(255) NOT NULL, + PRIMARY KEY (`id`), + KEY `category_id` (`category_id`), + KEY `user_id` (`user_id`), + CONSTRAINT `posts_category_id_fkey` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`), + CONSTRAINT `posts_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `posts` (`user_id`, `category_id`, `content`) VALUES +(1, 1, 'blog started'), +(1, 2, 'It works!'); + +DROP TABLE IF EXISTS `post_tags`; +CREATE TABLE `post_tags` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `post_id` int(11) NOT NULL, + `tag_id` int(11) NOT NULL, + PRIMARY KEY (`id`), + KEY `post_id` (`post_id`), + KEY `tag_id` (`tag_id`), + CONSTRAINT `post_tags_post_id_fkey` FOREIGN KEY (`post_id`) REFERENCES `posts` (`id`), + CONSTRAINT `post_tags_tag_id_fkey` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `post_tags` (`post_id`, `tag_id`) VALUES +(1, 1), +(1, 2), +(2, 1), +(2, 2); + +DROP TABLE IF EXISTS `tags`; +CREATE TABLE `tags` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `is_important` bit(1) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `tags` (`name`, `is_important`) VALUES +('funny', 0), +('important', 1); + +DROP TABLE IF EXISTS `users`; +CREATE TABLE `users` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `username` varchar(255) NOT NULL, + `password` varchar(255) NOT NULL, + `location` point, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `users` (`username`, `password`, `location`) VALUES +('user1', 'pass1', NULL), +('user2', '$2y$10$cg7/nswxVZ0cmVIsMB/pVOh1OfcHScBJGq7Xu4KF9dFEQgRZ8HWe.', NULL); + +DROP TABLE IF EXISTS `countries`; +CREATE TABLE `countries` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `shape` geometry NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `countries` (`name`, `shape`) VALUES +('Left', ST_GeomFromText('POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))')), +('Right', ST_GeomFromText('POLYGON ((70 10, 80 40, 60 40, 50 20, 70 10))')), +('Point', ST_GeomFromText('POINT (30 10)')), +('Line', ST_GeomFromText('LINESTRING (30 10, 10 30, 40 40)')), +('Poly1', ST_GeomFromText('POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))')), +('Poly2', ST_GeomFromText('POLYGON ((35 10, 45 45, 15 40, 10 20, 35 10),(20 30, 35 35, 30 20, 20 30))')), +('Mpoint', ST_GeomFromText('MULTIPOINT (10 40, 40 30, 20 20, 30 10)')), +('Mline', ST_GeomFromText('MULTILINESTRING ((10 10, 20 20, 10 40),(40 40, 30 30, 40 20, 30 10))')), +('Mpoly1', ST_GeomFromText('MULTIPOLYGON (((30 20, 45 40, 10 40, 30 20)),((15 5, 40 10, 10 20, 5 10, 15 5)))')), +('Mpoly2', ST_GeomFromText('MULTIPOLYGON (((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)))')), +('Gcoll', ST_GeomFromText('GEOMETRYCOLLECTION(POINT(4 6),LINESTRING(4 6,7 10))')); + +DROP TABLE IF EXISTS `events`; +CREATE TABLE `events` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `datetime` datetime, + `visitors` bigint(20), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `events` (`name`, `datetime`, `visitors`) VALUES +('Launch', '2016-01-01 13:01:01', 0); + +DROP VIEW IF EXISTS `tag_usage`; +CREATE VIEW `tag_usage` AS select `tags`.`id` as `id`, `name`, count(`name`) AS `count` from `tags`, `post_tags` where `tags`.`id` = `post_tags`.`tag_id` group by `tags`.`id`, `name` order by `count` desc, `name`; + +DROP TABLE IF EXISTS `products`; +CREATE TABLE `products` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `price` decimal(10,2) NOT NULL, + `properties` longtext NOT NULL, + `created_at` datetime NOT NULL, + `deleted_at` datetime, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `products` (`name`, `price`, `properties`, `created_at`) VALUES +('Calculator', '23.01', '{"depth":false,"model":"TRX-120","width":100,"height":null}', '1970-01-01 01:01:01'); + +DROP TABLE IF EXISTS `barcodes2`; +DROP TABLE IF EXISTS `barcodes`; +CREATE TABLE `barcodes` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `product_id` int(11) NOT NULL, + `hex` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + `bin` blob NOT NULL, + `ip_address` varchar(15), + PRIMARY KEY (`id`), + CONSTRAINT `barcodes_product_id_fkey` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `barcodes` (`product_id`, `hex`, `bin`, `ip_address`) VALUES +(1, '00ff01', UNHEX('00ff01'), '127.0.0.1'); + +DROP TABLE IF EXISTS `kunsthåndværk`; +CREATE TABLE `kunsthåndværk` ( + `id` varchar(36) NOT NULL, + `Umlauts ä_ö_ü-COUNT` int(11) NOT NULL, + `user_id` int(11) NOT NULL, + `invisible` varchar(36), + `invisible_id` varchar(36), + PRIMARY KEY (`id`), + CONSTRAINT `kunsthåndværk_Umlauts ä_ö_ü-COUNT_fkey` UNIQUE (`Umlauts ä_ö_ü-COUNT`), + CONSTRAINT `kunsthåndværk_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), + CONSTRAINT `kunsthåndværk_invisible_id_fkey` FOREIGN KEY (`invisible_id`) REFERENCES `invisibles` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `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'), +('e31ecfe6-591f-4660-9fbd-1a232083037f', 2, 2, NULL, 'e42c77c6-06a4-4502-816c-d112c7142e6d'); + +DROP TABLE IF EXISTS `invisibles`; +CREATE TABLE `invisibles` ( + `id` varchar(36) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `invisibles` (`id`) VALUES +('e42c77c6-06a4-4502-816c-d112c7142e6d'); + +DROP TABLE IF EXISTS `nopk`; +CREATE TABLE `nopk` ( + `id` varchar(36) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `nopk` (`id`) VALUES +('e42c77c6-06a4-4502-816c-d112c7142e6d'); + +SET foreign_key_checks = 1; + +-- 2016-11-05 13:11:47 diff --git a/tests/fixtures/blog_pgsql.sql b/tests/fixtures/blog_pgsql.sql new file mode 100644 index 0000000..1c8e48c --- /dev/null +++ b/tests/fixtures/blog_pgsql.sql @@ -0,0 +1,548 @@ +-- +-- PostgreSQL database dump +-- + +SET statement_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET check_function_bodies = false; +SET client_min_messages = warning; + +SET search_path = public, pg_catalog; + +SET default_tablespace = ''; + +SET default_with_oids = false; + +-- +-- Drop everything +-- + +DROP TABLE IF EXISTS categories CASCADE; +DROP TABLE IF EXISTS comments CASCADE; +DROP TABLE IF EXISTS post_tags CASCADE; +DROP TABLE IF EXISTS posts CASCADE; +DROP TABLE IF EXISTS tags CASCADE; +DROP TABLE IF EXISTS users CASCADE; +DROP TABLE IF EXISTS countries CASCADE; +DROP TABLE IF EXISTS events CASCADE; +DROP VIEW IF EXISTS tag_usage; +DROP TABLE IF EXISTS products CASCADE; +DROP TABLE IF EXISTS barcodes CASCADE; +DROP TABLE IF EXISTS barcodes2 CASCADE; +DROP TABLE IF EXISTS "kunsthåndværk" CASCADE; +DROP TABLE IF EXISTS invisibles CASCADE; +DROP TABLE IF EXISTS nopk CASCADE; + +-- +-- Name: categories; Type: TABLE; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE TABLE categories ( + id serial NOT NULL, + name character varying(255) NOT NULL, + icon bytea +); + + +-- +-- Name: comments; Type: TABLE; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE TABLE comments ( + id bigserial NOT NULL, + post_id integer NOT NULL, + message character varying(255) NOT NULL, + category_id integer NOT NULL +); + + +-- +-- Name: post_tags; Type: TABLE; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE TABLE post_tags ( + id serial NOT NULL, + post_id integer NOT NULL, + tag_id integer NOT NULL +); + + +-- +-- Name: posts; Type: TABLE; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE TABLE posts ( + id serial NOT NULL, + user_id integer NOT NULL, + category_id integer NOT NULL, + content character varying(255) NOT NULL +); + + +-- +-- Name: tags; Type: TABLE; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE TABLE tags ( + id serial NOT NULL, + name character varying(255) NOT NULL, + is_important boolean NOT NULL +); + + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE TABLE users ( + id serial NOT NULL, + username character varying(255) NOT NULL, + password character varying(255) NOT NULL, + location geometry +); + +-- +-- Name: countries; Type: TABLE; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE TABLE countries ( + id serial NOT NULL, + name character varying(255) NOT NULL, + shape geometry NOT NULL +); + +-- +-- Name: events; Type: TABLE; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE TABLE events ( + id serial NOT NULL, + name character varying(255) NOT NULL, + datetime timestamp, + visitors bigint +); + +-- +-- Name: tag_usage; Type: VIEW; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE VIEW "tag_usage" AS select "tags"."id" as "id", "name", count("name") AS "count" from "tags", "post_tags" where "tags"."id" = "post_tags"."tag_id" group by "tags"."id", "name" order by "count" desc, "name"; + +-- +-- Name: products; Type: TABLE; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE TABLE products ( + id serial NOT NULL, + name character varying(255) NOT NULL, + price decimal(10,2) NOT NULL, + properties jsonb NOT NULL, + created_at timestamp NOT NULL, + deleted_at timestamp NULL +); + +-- +-- Name: barcodes; Type: TABLE; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE TABLE barcodes ( + id serial NOT NULL, + product_id integer NOT NULL, + hex character varying(255) NOT NULL, + bin bytea NOT NULL, + ip_address character varying(15) +); + +-- +-- Name: kunsthåndværk; Type: TABLE; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE TABLE "kunsthåndværk" ( + id character varying(36) NOT NULL, + "Umlauts ä_ö_ü-COUNT" integer NOT NULL, + user_id integer NOT NULL, + invisible character varying(36), + invisible_id character varying(36) +); + +-- +-- Name: invisibles; Type: TABLE; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE TABLE "invisibles" ( + id character varying(36) NOT NULL +); + +-- +-- Name: nopk; Type: TABLE; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE TABLE "nopk" ( + id character varying(36) NOT NULL +); + +-- +-- Data for Name: categories; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +INSERT INTO "categories" ("name", "icon") VALUES +('announcement', NULL), +('article', NULL), +('comment', NULL); + +-- +-- Data for Name: comments; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +INSERT INTO "comments" ("post_id", "message", "category_id") VALUES +(1, 'great', 3), +(1, 'fantastic', 3), +(2, 'thank you', 3), +(2, 'awesome', 3); + +-- +-- Data for Name: post_tags; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +INSERT INTO "post_tags" ("post_id", "tag_id") VALUES +(1, 1), +(1, 2), +(2, 1), +(2, 2); + +-- +-- Data for Name: posts; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +INSERT INTO "posts" ("user_id", "category_id", "content") VALUES +(1, 1, 'blog started'), +(1, 2, 'It works!'); + +-- +-- Data for Name: tags; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +INSERT INTO "tags" ("name", "is_important") VALUES +('funny', false), +('important', true); + +-- +-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +INSERT INTO "users" ("username", "password", "location") VALUES +('user1', 'pass1', NULL), +('user2', '$2y$10$cg7/nswxVZ0cmVIsMB/pVOh1OfcHScBJGq7Xu4KF9dFEQgRZ8HWe.', NULL); + +-- +-- Data for Name: countries; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +INSERT INTO "countries" ("name", "shape") VALUES +('Left', ST_GeomFromText('POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))')), +('Right', ST_GeomFromText('POLYGON ((70 10, 80 40, 60 40, 50 20, 70 10))')), +('Point', ST_GeomFromText('POINT (30 10)')), +('Line', ST_GeomFromText('LINESTRING (30 10, 10 30, 40 40)')), +('Poly1', ST_GeomFromText('POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))')), +('Poly2', ST_GeomFromText('POLYGON ((35 10, 45 45, 15 40, 10 20, 35 10),(20 30, 35 35, 30 20, 20 30))')), +('Mpoint', ST_GeomFromText('MULTIPOINT (10 40, 40 30, 20 20, 30 10)')), +('Mline', ST_GeomFromText('MULTILINESTRING ((10 10, 20 20, 10 40),(40 40, 30 30, 40 20, 30 10))')), +('Mpoly1', ST_GeomFromText('MULTIPOLYGON (((30 20, 45 40, 10 40, 30 20)),((15 5, 40 10, 10 20, 5 10, 15 5)))')), +('Mpoly2', ST_GeomFromText('MULTIPOLYGON (((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)))')), +('Gcoll', ST_GeomFromText('GEOMETRYCOLLECTION(POINT(4 6),LINESTRING(4 6,7 10))')); + +-- +-- Data for Name: events; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +INSERT INTO "events" ("name", "datetime", "visitors") VALUES +('Launch', '2016-01-01 13:01:01', 0); + +-- +-- Data for Name: products; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +INSERT INTO "products" ("name", "price", "properties", "created_at") VALUES +('Calculator', '23.01', '{"depth":false,"model":"TRX-120","width":100,"height":null}', '1970-01-01 01:01:01'); + +-- +-- Data for Name: barcodes; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +INSERT INTO "barcodes" ("product_id", "hex", "bin", "ip_address") VALUES +(1, '00ff01', E'\\x00ff01', '127.0.0.1'); + +-- +-- Data for Name: kunsthåndværk; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +INSERT INTO "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'), +('e31ecfe6-591f-4660-9fbd-1a232083037f', 2, 2, NULL, 'e42c77c6-06a4-4502-816c-d112c7142e6d'); + +-- +-- Data for Name: invisibles; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +INSERT INTO "invisibles" ("id") VALUES +('e42c77c6-06a4-4502-816c-d112c7142e6d'); + +-- +-- Data for Name: nopk; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +INSERT INTO "nopk" ("id") VALUES +('e42c77c6-06a4-4502-816c-d112c7142e6d'); + +-- +-- Name: categories_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: +-- + +ALTER TABLE ONLY categories + ADD CONSTRAINT categories_pkey PRIMARY KEY (id); + + +-- +-- Name: comments_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: +-- + +ALTER TABLE ONLY comments + ADD CONSTRAINT comments_pkey PRIMARY KEY (id); + + +-- +-- Name: post_tags_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: +-- + +ALTER TABLE ONLY post_tags + ADD CONSTRAINT post_tags_pkey PRIMARY KEY (id); + + +-- +-- Name: post_tags_post_id_tag_id_key; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: +-- + +ALTER TABLE ONLY post_tags + ADD CONSTRAINT post_tags_post_id_tag_id_key UNIQUE (post_id, tag_id); + + +-- +-- Name: posts_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: +-- + +ALTER TABLE ONLY posts + ADD CONSTRAINT posts_pkey PRIMARY KEY (id); + + +-- +-- Name: tags_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: +-- + +ALTER TABLE ONLY tags + ADD CONSTRAINT tags_pkey PRIMARY KEY (id); + + +-- +-- Name: users_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: +-- + +ALTER TABLE ONLY users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + +-- +-- Name: countries_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: +-- + +ALTER TABLE ONLY countries + ADD CONSTRAINT countries_pkey PRIMARY KEY (id); + + +-- +-- Name: events_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: +-- + +ALTER TABLE ONLY events + ADD CONSTRAINT events_pkey PRIMARY KEY (id); + + +-- +-- Name: products_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: +-- + +ALTER TABLE ONLY products + ADD CONSTRAINT products_pkey PRIMARY KEY (id); + + +-- +-- Name: barcodes_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: +-- + +ALTER TABLE ONLY barcodes + ADD CONSTRAINT barcodes_pkey PRIMARY KEY (id); + +-- +-- Name: kunsthåndværk_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: +-- + +ALTER TABLE ONLY "kunsthåndværk" + ADD CONSTRAINT "kunsthåndværk_pkey" PRIMARY KEY (id); + +-- +-- Name: invisibles_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: +-- + +ALTER TABLE ONLY "invisibles" + ADD CONSTRAINT "invisibles_pkey" PRIMARY KEY (id); + +-- +-- Name: comments_post_id_idx; Type: INDEX; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE INDEX comments_post_id_idx ON comments USING btree (post_id); + +-- +-- Name: comments_category_id_idx; Type: INDEX; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE INDEX comments_category_id_idx ON comments USING btree (category_id); + + +-- +-- Name: post_tags_post_id_idx; Type: INDEX; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE INDEX post_tags_post_id_idx ON post_tags USING btree (post_id); + + +-- +-- Name: post_tags_tag_id_idx; Type: INDEX; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE INDEX post_tags_tag_id_idx ON post_tags USING btree (tag_id); + + +-- +-- Name: posts_category_id_idx; Type: INDEX; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE INDEX posts_category_id_idx ON posts USING btree (category_id); + + +-- +-- Name: posts_user_id_idx; Type: INDEX; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE INDEX posts_user_id_idx ON posts USING btree (user_id); + + +-- +-- Name: barcodes_product_id_idx; Type: INDEX; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE INDEX barcodes_product_id_idx ON barcodes USING btree (product_id); + + +-- +-- Name: kunsthåndværk_Umlauts ä_ö_ü-COUNT_idx; Type: INDEX; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE INDEX "kunsthåndværk_Umlauts ä_ö_ü-COUNT_idx" ON "kunsthåndværk" USING btree ("Umlauts ä_ö_ü-COUNT"); + + +-- +-- Name: kunsthåndværk_user_id_idx; Type: INDEX; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE INDEX "kunsthåndværk_user_id_idx" ON "kunsthåndværk" USING btree (user_id); + + +-- +-- Name: kunsthåndværk_invisible_id_idx; Type: INDEX; Schema: public; Owner: postgres; Tablespace: +-- + +CREATE INDEX "kunsthåndværk_invisible_id_idx" ON "kunsthåndværk" USING btree (invisible_id); + + +-- +-- Name: comments_post_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY comments + ADD CONSTRAINT comments_post_id_fkey FOREIGN KEY (post_id) REFERENCES posts(id); + + +-- +-- Name: comments_category_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY comments + ADD CONSTRAINT comments_category_id_fkey FOREIGN KEY (category_id) REFERENCES categories(id); + + +-- +-- Name: post_tags_post_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY post_tags + ADD CONSTRAINT post_tags_post_id_fkey FOREIGN KEY (post_id) REFERENCES posts(id); + + +-- +-- Name: post_tags_tag_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY post_tags + ADD CONSTRAINT post_tags_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES tags(id); + + +-- +-- Name: posts_category_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY posts + ADD CONSTRAINT posts_category_id_fkey FOREIGN KEY (category_id) REFERENCES categories(id); + + +-- +-- Name: posts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY posts + ADD CONSTRAINT posts_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); + + +-- +-- Name: barcodes_product_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY barcodes + ADD CONSTRAINT barcodes_product_id_fkey FOREIGN KEY (product_id) REFERENCES products(id); + + +-- +-- Name: kunsthåndværk_Umlauts ä_ö_ü-COUNT_uc; Type: UNIQUE CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY "kunsthåndværk" + ADD CONSTRAINT "kunsthåndværk_Umlauts ä_ö_ü-COUNT_uc" UNIQUE ("Umlauts ä_ö_ü-COUNT"); + + +-- +-- Name: kunsthåndværk_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY "kunsthåndværk" + ADD CONSTRAINT "kunsthåndværk_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id); + + +-- +-- Name: kunsthåndværk_invisible_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY "kunsthåndværk" + ADD CONSTRAINT "kunsthåndværk_invisible_id_fkey" FOREIGN KEY (invisible_id) REFERENCES invisibles(id); + +-- +-- PostgreSQL database dump complete +-- diff --git a/tests/fixtures/blog_sqlite.sql b/tests/fixtures/blog_sqlite.sql new file mode 100644 index 0000000..4099451 --- /dev/null +++ b/tests/fixtures/blog_sqlite.sql @@ -0,0 +1,175 @@ +-- Adminer 4.2.4 SQLite 3 dump + +PRAGMA foreign_keys = off; + +DROP TABLE IF EXISTS "categories"; +CREATE TABLE "categories" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" varchar(255) NOT NULL, + "icon" blob NULL +); + +INSERT INTO "categories" ("id", "name", "icon") VALUES (1, 'announcement', NULL); +INSERT INTO "categories" ("id", "name", "icon") VALUES (2, 'article', NULL); +INSERT INTO "categories" ("id", "name", "icon") VALUES (3, 'comment', NULL); + +DROP TABLE IF EXISTS "comments"; +CREATE TABLE "comments" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "post_id" integer NOT NULL, + "message" VARCHAR(255) NOT NULL, + "category_id" integer NOT NULL, + FOREIGN KEY ("post_id") REFERENCES "posts" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT, + FOREIGN KEY ("category_id") REFERENCES "categories" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT +); + +CREATE INDEX "comments_post_id" ON "comments" ("post_id"); +CREATE INDEX "comments_category_id" ON "comments" ("category_id"); + +INSERT INTO "comments" ("id", "post_id", "message", "category_id") VALUES (1, 1, 'great', 3); +INSERT INTO "comments" ("id", "post_id", "message", "category_id") VALUES (2, 1, 'fantastic', 3); +INSERT INTO "comments" ("id", "post_id", "message", "category_id") VALUES (3, 2, 'thank you', 3); +INSERT INTO "comments" ("id", "post_id", "message", "category_id") VALUES (4, 2, 'awesome', 3); + +DROP TABLE IF EXISTS "posts"; +CREATE TABLE "posts" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "user_id" integer NOT NULL, + "category_id" integer NOT NULL, + "content" varchar(255) NOT NULL, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT, + FOREIGN KEY ("category_id") REFERENCES "categories" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT +); + +CREATE INDEX "posts_user_id" ON "posts" ("user_id"); + +CREATE INDEX "posts_category_id" ON "posts" ("category_id"); + +INSERT INTO "posts" ("id", "user_id", "category_id", "content") VALUES (1, 1, 1, 'blog started'); +INSERT INTO "posts" ("id", "user_id", "category_id", "content") VALUES (2, 1, 2, 'It works!'); + +DROP TABLE IF EXISTS "post_tags"; +CREATE TABLE "post_tags" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "post_id" integer NOT NULL, + "tag_id" integer NOT NULL, + FOREIGN KEY ("tag_id") REFERENCES "tags" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT, + FOREIGN KEY ("post_id") REFERENCES "posts" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT +); + +CREATE UNIQUE INDEX "post_tags_post_id_tag_id" ON "post_tags" ("post_id", "tag_id"); + +INSERT INTO "post_tags" ("id", "post_id", "tag_id") VALUES (1, 1, 1); +INSERT INTO "post_tags" ("id", "post_id", "tag_id") VALUES (2, 1, 2); +INSERT INTO "post_tags" ("id", "post_id", "tag_id") VALUES (3, 2, 1); +INSERT INTO "post_tags" ("id", "post_id", "tag_id") VALUES (4, 2, 2); + +DROP TABLE IF EXISTS "tags"; +CREATE TABLE "tags" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" varchar(255) NOT NULL, + "is_important" boolean NOT NULL +); + +INSERT INTO "tags" ("id", "name", "is_important") VALUES (1, 'funny', 0); +INSERT INTO "tags" ("id", "name", "is_important") VALUES (2, 'important', 1); + +DROP TABLE IF EXISTS "users"; +CREATE TABLE "users" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "username" varchar(255) NOT NULL, + "password" varchar(255) NOT NULL, + "location" text NULL +); + +INSERT INTO "users" ("id", "username", "password", "location") VALUES (1, 'user1', 'pass1', NULL); +INSERT INTO "users" ("id", "username", "password", "location") VALUES (2, 'user2', '$2y$10$cg7/nswxVZ0cmVIsMB/pVOh1OfcHScBJGq7Xu4KF9dFEQgRZ8HWe.', NULL); + +DROP TABLE IF EXISTS "countries"; +CREATE TABLE "countries" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" varchar(255) NOT NULL, + "shape" text NOT NULL +); + +INSERT INTO "countries" ("id", "name", "shape") VALUES (1, 'Left', 'POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))'); +INSERT INTO "countries" ("id", "name", "shape") VALUES (2, 'Right', 'POLYGON ((70 10, 80 40, 60 40, 50 20, 70 10))'); +INSERT INTO "countries" ("id", "name", "shape") VALUES (3, 'Point', 'POINT (30 10)'); +INSERT INTO "countries" ("id", "name", "shape") VALUES (4, 'Line', 'LINESTRING (30 10, 10 30, 40 40)'); +INSERT INTO "countries" ("id", "name", "shape") VALUES (5, 'Poly1', 'POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))'); +INSERT INTO "countries" ("id", "name", "shape") VALUES (6, 'Poly2', 'POLYGON ((35 10, 45 45, 15 40, 10 20, 35 10),(20 30, 35 35, 30 20, 20 30))'); +INSERT INTO "countries" ("id", "name", "shape") VALUES (7, 'Mpoint', 'MULTIPOINT (10 40, 40 30, 20 20, 30 10)'); +INSERT INTO "countries" ("id", "name", "shape") VALUES (8, 'Mline', 'MULTILINESTRING ((10 10, 20 20, 10 40),(40 40, 30 30, 40 20, 30 10))'); +INSERT INTO "countries" ("id", "name", "shape") VALUES (9, 'Mpoly1', 'MULTIPOLYGON (((30 20, 45 40, 10 40, 30 20)),((15 5, 40 10, 10 20, 5 10, 15 5)))'); +INSERT INTO "countries" ("id", "name", "shape") VALUES (10, 'Mpoly2', 'MULTIPOLYGON (((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)))'); +INSERT INTO "countries" ("id", "name", "shape") VALUES (11, 'Gcoll', 'GEOMETRYCOLLECTION(POINT(4 6),LINESTRING(4 6,7 10))'); + +DROP TABLE IF EXISTS "events"; +CREATE TABLE "events" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" varchar(255) NOT NULL, + "datetime" datetime, + "visitors" bigint +); + +INSERT INTO "events" ("id", "name", "datetime", "visitors") VALUES (1, 'Launch', '2016-01-01 13:01:01', 0); + +DROP VIEW IF EXISTS "tag_usage"; +CREATE VIEW "tag_usage" AS select "tags"."id" as "id", "name", count("name") AS "count" from "tags", "post_tags" where "tags"."id" = "post_tags"."tag_id" group by "tags"."id", "name" order by "count" desc, "name"; + +DROP TABLE IF EXISTS "products"; +CREATE TABLE "products" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" varchar(255) NOT NULL, + "price" decimal(10,2) NOT NULL, + "properties" clob NOT NULL, + "created_at" datetime NOT NULL, + "deleted_at" datetime NULL +); + +INSERT INTO "products" ("id", "name", "price", "properties", "created_at") VALUES (1, 'Calculator', '23.01', '{"depth":false,"model":"TRX-120","width":100,"height":null}', '1970-01-01 01:01:01'); + +DROP TABLE IF EXISTS "barcodes2"; +DROP TABLE IF EXISTS "barcodes"; +CREATE TABLE "barcodes" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "product_id" integer NOT NULL, + "hex" varchar(255) NOT NULL, + "bin" blob NOT NULL, + "ip_address" varchar(15), + FOREIGN KEY ("product_id") REFERENCES "products" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT +); + +INSERT INTO "barcodes" ("id", "product_id", "hex", "bin", "ip_address") VALUES (1, 1, '00ff01', 'AP8B', '127.0.0.1'); + +DROP TABLE IF EXISTS "kunsthåndværk"; +CREATE TABLE "kunsthåndværk" ( + "id" varchar(36) NOT NULL PRIMARY KEY, + "Umlauts ä_ö_ü-COUNT" integer NOT NULL UNIQUE, + "user_id" integer NOT NULL, + "invisible" varchar(36), + "invisible_id" varchar(36), + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT, + FOREIGN KEY ("invisible_id") REFERENCES "invisibles" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT +); + +INSERT INTO "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'); +INSERT INTO "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'); + +DROP TABLE IF EXISTS "invisibles"; +CREATE TABLE "invisibles" ( + "id" varchar(36) NOT NULL PRIMARY KEY +); + +INSERT INTO "invisibles" ("id") VALUES ('e42c77c6-06a4-4502-816c-d112c7142e6d'); + +DROP TABLE IF EXISTS "nopk"; +CREATE TABLE "nopk" ( + "id" varchar(36) NOT NULL +); + +INSERT INTO "nopk" ("id") VALUES ('e42c77c6-06a4-4502-816c-d112c7142e6d'); + +PRAGMA foreign_keys = on; + +-- \ No newline at end of file diff --git a/tests/fixtures/blog_sqlsrv.sql b/tests/fixtures/blog_sqlsrv.sql new file mode 100644 index 0000000..3c95e60 --- /dev/null +++ b/tests/fixtures/blog_sqlsrv.sql @@ -0,0 +1,442 @@ +IF (OBJECT_ID('kunsthåndværk_user_id_fkey', 'F') IS NOT NULL) +BEGIN +ALTER TABLE [kunsthåndværk] DROP CONSTRAINT [kunsthåndværk_user_id_fkey] +END +GO + +IF (OBJECT_ID('barcodes_product_id_fkey', 'F') IS NOT NULL) +BEGIN +ALTER TABLE [barcodes] DROP CONSTRAINT [barcodes_product_id_fkey] +END +GO + +IF (OBJECT_ID('posts_user_id_fkey', 'F') IS NOT NULL) +BEGIN +ALTER TABLE [posts] DROP CONSTRAINT [posts_user_id_fkey] +END +GO + +IF (OBJECT_ID('posts_category_id_fkey', 'F') IS NOT NULL) +BEGIN +ALTER TABLE [posts] DROP CONSTRAINT [posts_category_id_fkey] +END +GO + +IF (OBJECT_ID('post_tags_tag_id_fkey', 'F') IS NOT NULL) +BEGIN +ALTER TABLE [post_tags] DROP CONSTRAINT [post_tags_tag_id_fkey] +END +GO + +IF (OBJECT_ID('post_tags_post_id_fkey', 'F') IS NOT NULL) +BEGIN +ALTER TABLE [post_tags] DROP CONSTRAINT [post_tags_post_id_fkey] +END +GO + +IF (OBJECT_ID('comments_post_id_fkey', 'F') IS NOT NULL) +BEGIN +ALTER TABLE [comments] DROP CONSTRAINT [comments_post_id_fkey] +END +GO + +IF (OBJECT_ID('comments_category_id_fkey', 'F') IS NOT NULL) +BEGIN +ALTER TABLE [comments] DROP CONSTRAINT [comments_category_id_fkey] +END +GO + +IF (OBJECT_ID('barcodes2', 'U') IS NOT NULL) +BEGIN +DROP TABLE [barcodes2] +END +GO + +IF (OBJECT_ID('barcodes', 'U') IS NOT NULL) +BEGIN +DROP TABLE [barcodes] +END +GO + +IF (OBJECT_ID('products', 'U') IS NOT NULL) +BEGIN +DROP TABLE [products] +END +GO + +IF (OBJECT_ID('events', 'U') IS NOT NULL) +BEGIN +DROP TABLE [events] +END +GO + +IF (OBJECT_ID('countries', 'U') IS NOT NULL) +BEGIN +DROP TABLE [countries] +END +GO + +IF (OBJECT_ID('users', 'U') IS NOT NULL) +BEGIN +DROP TABLE [users] +END +GO + +IF (OBJECT_ID('tags', 'U') IS NOT NULL) +BEGIN +DROP TABLE [tags] +END +GO + +IF (OBJECT_ID('posts', 'U') IS NOT NULL) +BEGIN +DROP TABLE [posts] +END +GO + +IF (OBJECT_ID('post_tags', 'U') IS NOT NULL) +BEGIN +DROP TABLE [post_tags] +END +GO + +IF (OBJECT_ID('comments', 'U') IS NOT NULL) +BEGIN +DROP TABLE [comments] +END +GO + +IF (OBJECT_ID('categories', 'U') IS NOT NULL) +BEGIN +DROP TABLE [categories] +END +GO + +IF (OBJECT_ID('tag_usage', 'V') IS NOT NULL) +BEGIN +DROP VIEW [tag_usage] +END +GO + +IF (OBJECT_ID('kunsthåndværk', 'U') IS NOT NULL) +BEGIN +DROP TABLE [kunsthåndværk] +END +GO + +IF (OBJECT_ID('invisibles', 'U') IS NOT NULL) +BEGIN +DROP TABLE [invisibles] +END +GO + +IF (OBJECT_ID('nopk', 'U') IS NOT NULL) +BEGIN +DROP TABLE [nopk] +END +GO + +DROP SEQUENCE IF EXISTS [categories_id_seq] +GO +CREATE SEQUENCE [categories_id_seq] AS int START WITH 1 INCREMENT BY 1 NO CACHE +GO + +CREATE TABLE [categories]( + [id] [int] NOT NULL CONSTRAINT [categories_id_def] DEFAULT NEXT VALUE FOR [categories_id_seq], + [name] [nvarchar](255) NOT NULL, + [icon] [image], + CONSTRAINT [categories_pkey] PRIMARY KEY CLUSTERED([id] ASC) +) +GO + +DROP SEQUENCE IF EXISTS [comments_id_seq] +GO +CREATE SEQUENCE [comments_id_seq] AS bigint START WITH 1 INCREMENT BY 1 NO CACHE +GO + +CREATE TABLE [comments]( + [id] [bigint] NOT NULL CONSTRAINT [comments_id_def] DEFAULT NEXT VALUE FOR [comments_id_seq], + [post_id] [int] NOT NULL, + [message] [nvarchar](255) NOT NULL, + [category_id] [int] NOT NULL, + CONSTRAINT [comments_pkey] PRIMARY KEY CLUSTERED([id] ASC) +) +GO + +DROP SEQUENCE IF EXISTS [post_tags_id_seq] +GO +CREATE SEQUENCE [post_tags_id_seq] AS int START WITH 1 INCREMENT BY 1 NO CACHE +GO + +CREATE TABLE [post_tags]( + [id] [int] NOT NULL CONSTRAINT [post_tags_id_def] DEFAULT NEXT VALUE FOR [post_tags_id_seq], + [post_id] [int] NOT NULL, + [tag_id] [int] NOT NULL, + CONSTRAINT [post_tags_pkey] PRIMARY KEY CLUSTERED([id] ASC) +) +GO + +DROP SEQUENCE IF EXISTS [posts_id_seq] +GO +CREATE SEQUENCE [posts_id_seq] AS int START WITH 1 INCREMENT BY 1 NO CACHE +GO + +CREATE TABLE [posts]( + [id] [int] NOT NULL CONSTRAINT [posts_id_def] DEFAULT NEXT VALUE FOR [posts_id_seq], + [user_id] [int] NOT NULL, + [category_id] [int] NOT NULL, + [content] [nvarchar](255) NOT NULL, + CONSTRAINT [posts_pkey] PRIMARY KEY CLUSTERED([id] ASC) +) +GO + +DROP SEQUENCE IF EXISTS [tags_id_seq] +GO +CREATE SEQUENCE [tags_id_seq] AS int START WITH 1 INCREMENT BY 1 NO CACHE +GO + +CREATE TABLE [tags]( + [id] [int] NOT NULL CONSTRAINT [tags_id_def] DEFAULT NEXT VALUE FOR [tags_id_seq], + [name] [nvarchar](255) NOT NULL, + [is_important] [bit] NOT NULL, + CONSTRAINT [tags_pkey] PRIMARY KEY CLUSTERED([id] ASC) +) +GO + +DROP SEQUENCE IF EXISTS [users_id_seq] +GO +CREATE SEQUENCE [users_id_seq] AS int START WITH 1 INCREMENT BY 1 NO CACHE +GO + +CREATE TABLE [users]( + [id] [int] NOT NULL CONSTRAINT [users_id_def] DEFAULT NEXT VALUE FOR [users_id_seq], + [username] [nvarchar](255) NOT NULL, + [password] [nvarchar](255) NOT NULL, + [location] [geometry], + CONSTRAINT [users_pkey] PRIMARY KEY CLUSTERED([id] ASC) +) +GO + +DROP SEQUENCE IF EXISTS [countries_id_seq] +GO +CREATE SEQUENCE [countries_id_seq] AS int START WITH 1 INCREMENT BY 1 NO CACHE +GO + +CREATE TABLE [countries]( + [id] [int] NOT NULL CONSTRAINT [countries_id_def] DEFAULT NEXT VALUE FOR [countries_id_seq], + [name] [nvarchar](255) NOT NULL, + [shape] [geometry] NOT NULL, + CONSTRAINT [countries_pkey] PRIMARY KEY CLUSTERED([id] ASC) +) +GO + +DROP SEQUENCE IF EXISTS [events_id_seq] +GO +CREATE SEQUENCE [events_id_seq] AS int START WITH 1 INCREMENT BY 1 NO CACHE +GO + +CREATE TABLE [events]( + [id] [int] NOT NULL CONSTRAINT [events_id_def] DEFAULT NEXT VALUE FOR [events_id_seq], + [name] [nvarchar](255) NOT NULL, + [datetime] [datetime2](0), + [visitors] [bigint], + CONSTRAINT [events_pkey] PRIMARY KEY CLUSTERED([id] ASC) +) +GO + +CREATE VIEW [tag_usage] +AS +SELECT top 100 PERCENT tags.id as id, name, COUNT_BIG(name) AS [count] FROM tags, post_tags WHERE tags.id = post_tags.tag_id GROUP BY tags.id, name ORDER BY [count] DESC, name +GO + +DROP SEQUENCE IF EXISTS [products_id_seq] +GO +CREATE SEQUENCE [products_id_seq] AS int START WITH 1 INCREMENT BY 1 NO CACHE +GO + +CREATE TABLE [products]( + [id] [int] NOT NULL CONSTRAINT [products_id_def] DEFAULT NEXT VALUE FOR [products_id_seq], + [name] [nvarchar](255) NOT NULL, + [price] [decimal](10,2) NOT NULL, + [properties] [xml] NOT NULL, + [created_at] [datetime2](0) NOT NULL, + [deleted_at] [datetime2](0), + CONSTRAINT [products_pkey] PRIMARY KEY CLUSTERED([id] ASC) +) +GO + +DROP SEQUENCE IF EXISTS [barcodes_id_seq] +GO +CREATE SEQUENCE [barcodes_id_seq] AS int START WITH 1 INCREMENT BY 1 NO CACHE +GO + +CREATE TABLE [barcodes]( + [id] [int] NOT NULL CONSTRAINT [barcodes_id_def] DEFAULT NEXT VALUE FOR [barcodes_id_seq], + [product_id] [int] NOT NULL, + [hex] [nvarchar](255) NOT NULL, + [bin] [varbinary](max) NOT NULL, + [ip_address] [nvarchar](15), + CONSTRAINT [barcodes_pkey] PRIMARY KEY CLUSTERED([id] ASC) +) +GO + +CREATE TABLE [kunsthåndværk]( + [id] [nvarchar](36) NOT NULL, + [Umlauts ä_ö_ü-COUNT] [int] NOT NULL, + [user_id] [int] NOT NULL, + [invisible] [nvarchar](36), + [invisible_id] [nvarchar](36), + CONSTRAINT [kunsthåndværk_pkey] PRIMARY KEY CLUSTERED([id] ASC) +) +GO + +CREATE TABLE [invisibles]( + [id] [nvarchar](36) NOT NULL, + CONSTRAINT [invisibles_pkey] PRIMARY KEY CLUSTERED([id] ASC) +) +GO + +CREATE TABLE [nopk]( + [id] [nvarchar](36) NOT NULL +) +GO + +INSERT [categories] ([name], [icon]) VALUES (N'announcement', NULL) +GO +INSERT [categories] ([name], [icon]) VALUES (N'article', NULL) +GO +INSERT [categories] ([name], [icon]) VALUES (N'comment', NULL) +GO + +INSERT [comments] ([post_id], [message], [category_id]) VALUES (1, N'great', 3) +GO +INSERT [comments] ([post_id], [message], [category_id]) VALUES (1, N'fantastic', 3) +GO +INSERT [comments] ([post_id], [message], [category_id]) VALUES (2, N'thank you', 3) +GO +INSERT [comments] ([post_id], [message], [category_id]) VALUES (2, N'awesome', 3) +GO + +INSERT [post_tags] ([post_id], [tag_id]) VALUES (1, 1) +GO +INSERT [post_tags] ([post_id], [tag_id]) VALUES (1, 2) +GO +INSERT [post_tags] ([post_id], [tag_id]) VALUES (2, 1) +GO +INSERT [post_tags] ([post_id], [tag_id]) VALUES (2, 2) +GO + +INSERT [posts] ([user_id], [category_id], [content]) VALUES (1, 1, N'blog started') +GO +INSERT [posts] ([user_id], [category_id], [content]) VALUES (1, 2, N'It works!') +GO + +INSERT [tags] ([name], [is_important]) VALUES (N'funny', 0) +GO +INSERT [tags] ([name], [is_important]) VALUES (N'important', 1) +GO + +INSERT [users] ([username], [password], [location]) VALUES (N'user1', N'pass1', NULL) +GO +INSERT [users] ([username], [password], [location]) VALUES (N'user2', N'$2y$10$cg7/nswxVZ0cmVIsMB/pVOh1OfcHScBJGq7Xu4KF9dFEQgRZ8HWe.', NULL) +GO + +INSERT [countries] ([name], [shape]) VALUES (N'Left', N'POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))') +GO +INSERT [countries] ([name], [shape]) VALUES (N'Right', N'POLYGON ((70 10, 80 40, 60 40, 50 20, 70 10))') +GO +INSERT [countries] ([name], [shape]) VALUES (N'Point', N'POINT (30 10)') +GO +INSERT [countries] ([name], [shape]) VALUES (N'Line', N'LINESTRING (30 10, 10 30, 40 40)') +GO +INSERT [countries] ([name], [shape]) VALUES (N'Poly1', N'POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))') +GO +INSERT [countries] ([name], [shape]) VALUES (N'Poly2', N'POLYGON ((35 10, 45 45, 15 40, 10 20, 35 10),(20 30, 35 35, 30 20, 20 30))') +GO +INSERT [countries] ([name], [shape]) VALUES (N'Mpoint', N'MULTIPOINT (10 40, 40 30, 20 20, 30 10)') +GO +INSERT [countries] ([name], [shape]) VALUES (N'Mline', N'MULTILINESTRING ((10 10, 20 20, 10 40),(40 40, 30 30, 40 20, 30 10))') +GO +INSERT [countries] ([name], [shape]) VALUES (N'Mpoly1', N'MULTIPOLYGON (((30 20, 45 40, 10 40, 30 20)),((15 5, 40 10, 10 20, 5 10, 15 5)))') +GO +INSERT [countries] ([name], [shape]) VALUES (N'Mpoly2', N'MULTIPOLYGON (((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)))') +GO +INSERT [countries] ([name], [shape]) VALUES (N'Gcoll', N'GEOMETRYCOLLECTION(POINT(4 6),LINESTRING(4 6,7 10))') +GO + +INSERT [events] ([name], [datetime], [visitors]) VALUES (N'Launch', N'2016-01-01 13:01:01', 0) +GO + +INSERT [products] ([name], [price], [properties], [created_at]) VALUES (N'Calculator', N'23.01', N'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 @@ +