19 Commits

Author SHA1 Message Date
18a182f3cd feat: implement gRPC Greeter service with example proto and enhance makefile
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 18s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Failing after 30s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Failing after 19s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Failing after 13s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Failing after 3s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Failing after 23s
2025-12-04 00:04:57 -05:00
373035d2cc feat: remove outdated PHP gRPC build instructions from makefile 2025-12-03 23:28:46 -05:00
92623941af feat: add gRPC server configuration and initial implementation with example proto 2025-12-03 23:25:41 -05:00
1ac5075b37 fix: update ServerErrorResponseTest to use dynamic file paths for exceptions
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m32s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m47s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m43s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m49s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m57s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m50s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 16m55s
🏗️✨ Build Workflow / 🖥️ 🔨 Build Migrations (push) Successful in 1m45s
2025-12-01 15:48:52 -05:00
ba2beca107 feat: implement NotFoundResponse and ServerErrorResponse classes with corresponding tests
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 4m16s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 4m17s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 4m27s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 4m32s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 4m15s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Failing after 3m1s
2025-12-01 14:55:34 -05:00
b5779afde9 feat: add unit tests for OpenApiController to validate YAML and JSON responses
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m37s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m35s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m52s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m43s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m45s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m19s
2025-12-01 11:41:09 -05:00
c91f35c0b1 feat: add unit tests for OpenApiController to validate YAML and JSON responses
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m34s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m37s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m30s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m39s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m43s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m19s
2025-12-01 11:30:46 -05:00
88098837a3 feat/swagger (#24)
Some checks are pending
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Waiting to run
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m39s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m44s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m41s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m55s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m59s
Reviewed-on: #24
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-12-01 16:22:42 +00:00
cd49507140 feat: add unit tests for User model name and email formatting
All checks were successful
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m57s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 3m7s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m8s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m42s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m19s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m55s
2025-11-30 19:43:55 -05:00
7792cac8b8 feat: add unit tests for User model name and email formatting
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 3m59s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Failing after 3m51s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 4m4s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 4m25s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 4m10s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Has been cancelled
2025-11-30 19:39:49 -05:00
eaff49b6a4 feat: add event dispatcher destructor and implement subscriber interface with tests (#23)
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 2m2s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Failing after 1m52s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Failing after 1m58s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m30s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m29s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m24s
Reviewed-on: #23
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-30 20:28:22 +00:00
721008bdfc feat: implement Guzzle facade and update JwtMiddleware to use it (#22)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m59s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m55s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m9s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m5s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m51s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 3m11s
Reviewed-on: #22
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-25 16:51:45 +00:00
a9a5cb6216 chore: update Dockerfile to use official PHP CLI image for version 8.5.0
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m4s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m32s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m32s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m45s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m28s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m10s
2025-11-22 10:43:34 -05:00
0504956d9a chore: update PHP version in composer.json and Dockerfile from 8.4 to 8.5
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 3m7s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m43s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 6m47s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 3m8s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m58s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m43s
2025-11-22 10:36:13 -05:00
e9d4cee336 chore: update Postgres version in test configuration from 17 to 18
All checks were successful
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 4m4s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 4m15s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 4m40s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 4m52s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 4m43s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 3m9s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 17m11s
🏗️✨ Build Workflow / 🖥️ 🔨 Build Migrations (push) Successful in 2m47s
2025-11-20 09:10:30 -05:00
7d9eb96bea fix: make Scope attribute repeatable and improve scope handling in middleware (#21)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m55s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m55s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m58s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m1s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m40s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m0s
Reviewed-on: #21
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-19 19:32:52 +00:00
9b736eb879 feat: add JWK support for JWT validation and update dependencies (#20)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 3m4s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m59s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m29s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 4m2s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m48s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m49s
Reviewed-on: #20
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-17 23:22:53 +00:00
7aa14c0db3 more tests (#19)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m32s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m48s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m33s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m44s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m53s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 3m5s
Reviewed-on: #19
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-16 16:40:09 +00:00
474134c654 chore: add deployment configurations and tests for logger and dispatcher (#18)
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m54s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m48s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 3m9s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 3m9s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m4s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m44s
Reviewed-on: #18
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-14 18:04:49 +00:00
71 changed files with 3551 additions and 334 deletions

View File

@@ -26,6 +26,12 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Write Version File
run: |
echo $GITEA_REF_NAME > VERSION
sed -i "s/dev-version/${GITEA_REF_NAME}/g" src/Helpers/Version.php
- name: 🏗️ 🔧 Set up Docker Buildx - name: 🏗️ 🔧 Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3

View File

@@ -38,7 +38,7 @@ jobs:
-e POSTGRES_PASSWORD=postgres \ -e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=postgres \ -e POSTGRES_DB=postgres \
-p 5432 \ -p 5432 \
-d postgres:17 -d postgres:18
echo "Waiting for Postgres to start" echo "Waiting for Postgres to start"
sleep 10 sleep 10

View File

@@ -6,6 +6,20 @@ server:
rpc: rpc:
listen: tcp://127.0.0.1:6001 listen: tcp://127.0.0.1:6001
grpc:
listen: "tcp://0.0.0.0:9001"
pool:
command: "php grpc-worker.php"
num_workers: ${GRPC_WORKERS:-4}
allocate_timeout: 5s
reset_timeout: 5s
destroy_timeout: 5s
stream_timeout: 5s
reflection: ${GRPC_REFLECTION:-true}
health_check: ${GRPC_HEALTH_CHECK:-true}
proto:
- "protos/example.proto"
http: http:
pool: pool:
allocate_timeout: 5s allocate_timeout: 5s

View File

@@ -5,7 +5,7 @@
<option name="interpreterName" value="composer-runtime" /> <option name="interpreterName" value="composer-runtime" />
</PhpTestInterpreterSettings> </PhpTestInterpreterSettings>
</CommandLine> </CommandLine>
<TestRunner configuration_file="$PROJECT_DIR$/phpunit.xml" scope="XML" use_alternative_configuration_file="true" /> <TestRunner configuration_file="$PROJECT_DIR$/phpunit.xml" coverage_engine="PCov" scope="XML" use_alternative_configuration_file="true" />
<method v="2" /> <method v="2" />
</configuration> </configuration>
</component> </component>

View File

@@ -12,7 +12,7 @@ RUN composer install --optimize-autoloader --ignore-platform-reqs --no-dev
# Use the official PHP CLI image with Alpine Linux for the second stage # Use the official PHP CLI image with Alpine Linux for the second stage
FROM php:8.4.14-alpine AS php FROM siteworxpro/php:8.5.0-cli-alpine AS php
ARG KAFKA_ENABLED=0 ARG KAFKA_ENABLED=0
@@ -42,6 +42,7 @@ COPY --from=library /app/vendor /app/vendor
# Copy the RoadRunner configuration file and source # Copy the RoadRunner configuration file and source
ADD src src/ ADD src src/
ADD generated generated/
ADD server.php . ADD server.php .
ADD .rr.yaml . ADD .rr.yaml .
ADD config.php . ADD config.php .

View File

@@ -4,9 +4,9 @@ echo "Installing xDebug"
apk add make gcc linux-headers autoconf alpine-sdk apk add make gcc linux-headers autoconf alpine-sdk
curl -sL https://github.com/xdebug/xdebug/archive/3.4.0.tar.gz -o 3.4.0.tar.gz curl -sL https://github.com/xdebug/xdebug/archive/3.5.0alpha3.tar.gz -o 3.5.0alpha3.tar.gz
tar -xvf 3.4.0.tar.gz tar -xvf 3.5.0alpha3.tar.gz
cd xdebug-3.4.0 || exit cd xdebug-3.5.0alpha3 || exit
phpize phpize
./configure --enable-xdebug ./configure --enable-xdebug
make make
@@ -20,5 +20,5 @@ xdebug.client_host = host.docker.internal
" > /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini " > /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
cd .. cd ..
rm -rf xdebug-3.4.0 rm -rf xdebug-3.5.0alpha3
rm -rf 3.4.0.tar.gz rm -rf 3.5.0alpha3.tar.gz

View File

@@ -4,11 +4,12 @@
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Siteworxpro\\App\\": "src/", "Siteworxpro\\App\\": "src/",
"Siteworxpro\\Tests\\": "tests/" "Siteworxpro\\Tests\\": "tests/",
"GRPC\\": "generated/GRPC"
} }
}, },
"require": { "require": {
"php": "^8.4", "php": "^8.5",
"league/route": "^6.2.0", "league/route": "^6.2.0",
"illuminate/database": "^v12.34.0", "illuminate/database": "^v12.34.0",
"spiral/roadrunner-http": "^v3.6.0", "spiral/roadrunner-http": "^v3.6.0",
@@ -21,12 +22,17 @@
"lcobucci/jwt": "^5.6", "lcobucci/jwt": "^5.6",
"adhocore/cli": "^1.9", "adhocore/cli": "^1.9",
"robinvdvleuten/ulid": "^5.0", "robinvdvleuten/ulid": "^5.0",
"monolog/monolog": "^3.9" "monolog/monolog": "^3.9",
"react/promise": "^3",
"react/async": "^4",
"guzzlehttp/guzzle": "^7.10",
"zircote/swagger-php": "^5.7",
"spiral/roadrunner-grpc": "^3.5"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^12.4", "phpunit/phpunit": "^12.4",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"squizlabs/php_codesniffer": "^3.12", "squizlabs/php_codesniffer": "^4.0",
"lendable/composer-license-checker": "^1.2", "lendable/composer-license-checker": "^1.2",
"phpstan/phpstan": "^2.1.31", "phpstan/phpstan": "^2.1.31",
"kwn/php-rdkafka-stubs": "^2.2" "kwn/php-rdkafka-stubs": "^2.2"

1433
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,11 @@ use Siteworxpro\App\Helpers\Env;
return [ return [
'app' => [
'log_level' => Env::get('LOG_LEVEL', 'debug'),
'dev_mode' => Env::get('DEV_MODE', false, 'bool'),
],
/** /**
* The server configuration. * The server configuration.
*/ */
@@ -47,7 +52,7 @@ return [
'signing_key' => Env::get('JWT_SIGNING_KEY', 'a_super_secret_key'), 'signing_key' => Env::get('JWT_SIGNING_KEY', 'a_super_secret_key'),
'audience' => Env::get('JWT_AUDIENCE', 'my_audience'), 'audience' => Env::get('JWT_AUDIENCE', 'my_audience'),
'issuer' => Env::get('JWT_ISSUER', 'my_issuer'), 'issuer' => Env::get('JWT_ISSUER', 'my_issuer'),
'strict_validation' => Env::get('JWT_STRICT_VALIDATION', true, 'bool'), 'strict_validation' => Env::get('JWT_STRICT_VALIDATION', false, 'bool'),
], ],
'queue' => [ 'queue' => [

View File

@@ -37,6 +37,20 @@ services:
environment: environment:
PHP_IDE_CONFIG: serverName=localhost PHP_IDE_CONFIG: serverName=localhost
swagger-ui:
labels:
- "traefik.enable=true"
- "traefik.http.routers.swagger-ui.entrypoints=web-secure"
- "traefik.http.routers.swagger-ui.rule=Host(`localhost`) && PathPrefix(`/docs`)"
- "traefik.http.routers.swagger-ui.tls=true"
- "traefik.http.routers.swagger-ui.service=swagger-ui"
- "traefik.http.services.swagger-ui.loadbalancer.server.port=8080"
image: swaggerapi/swagger-ui:latest
container_name: swagger-ui
environment:
BASE_URL: /docs
URL: /.well-known/swagger.yaml
migration-container: migration-container:
volumes: volumes:
- ./db/migrations:/app/db/migrations - ./db/migrations:/app/db/migrations
@@ -83,13 +97,16 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: environment:
JWT_ISSUER: https://auth.siteworxpro.com/application/o/postman/
JWT_AUDIENCE: 1RWyqJFlyA4hmsDzq6kSxs0LXvk7UgEAfgmBCpQ9
JWT_SIGNING_KEY: https://auth.siteworxpro.com/application/o/postman/.well-known/openid-configuration
QUEUE_BROKER: redis QUEUE_BROKER: redis
PHP_IDE_CONFIG: serverName=localhost PHP_IDE_CONFIG: serverName=localhost
WORKERS: 1 WORKERS: 1
DEBUG: 1 DEBUG: 1
REDIS_HOST: redis REDIS_HOST: redis
DB_HOST: postgres DB_HOST: postgres
JWT_SIGNING_KEY: a-string-secret-at-least-256-bits-long DEV_MODE: 1
## Kafka and Zookeeper for local development ## Kafka and Zookeeper for local development
kafka-ui: kafka-ui:

View File

@@ -0,0 +1,25 @@
<?php
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: protos/example.proto
namespace GRPC\GPBMetadata;
class Example
{
public static $is_initialized = false;
public static function initOnce() {
$pool = \Google\Protobuf\Internal\DescriptorPool::getGeneratedPool();
if (static::$is_initialized == true) {
return;
}
$pool->internalAddGeneratedFile(
"\x0A\xE5\x01\x0A\x14protos/example.proto\x12\x0Ahelloworld\"\x1C\x0A\x0CHelloRequest\x12\x0C\x0A\x04name\x18\x01 \x01(\x09\"\x1D\x0A\x0AHelloReply\x12\x0F\x0A\x07message\x18\x01 \x01(\x092I\x0A\x07Greeter\x12>\x0A\x08SayHello\x12\x18.helloworld.HelloRequest\x1A\x16.helloworld.HelloReply\"\x00B1Z\x0Dproto/greeter\xCA\x02\x0CGRPC\\Greeter\xE2\x02\x10GRPC\\GPBMetadatab\x06proto3"
, true);
static::$is_initialized = true;
}
}

View File

@@ -0,0 +1,22 @@
<?php
# Generated by the protocol buffer compiler (roadrunner-server/grpc). DO NOT EDIT!
# source: protos/example.proto
namespace GRPC\Greeter;
use Spiral\RoadRunner\GRPC;
interface GreeterInterface extends GRPC\ServiceInterface
{
// GRPC specific service name.
public const NAME = "helloworld.Greeter";
/**
* @param GRPC\ContextInterface $ctx
* @param HelloRequest $in
* @return HelloReply
*
* @throws GRPC\Exception\InvokeException
*/
public function SayHello(GRPC\ContextInterface $ctx, HelloRequest $in): HelloReply;
}

View File

@@ -0,0 +1,61 @@
<?php
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: protos/example.proto
namespace GRPC\Greeter;
use Google\Protobuf\Internal\GPBType;
use Google\Protobuf\Internal\GPBUtil;
use Google\Protobuf\RepeatedField;
/**
* The response message containing the greetings
*
* Generated from protobuf message <code>helloworld.HelloReply</code>
*/
class HelloReply extends \Google\Protobuf\Internal\Message
{
/**
* Generated from protobuf field <code>string message = 1;</code>
*/
protected $message = '';
/**
* Constructor.
*
* @param array $data {
* Optional. Data for populating the Message object.
*
* @type string $message
* }
*/
public function __construct($data = NULL) {
\GRPC\GPBMetadata\Example::initOnce();
parent::__construct($data);
}
/**
* Generated from protobuf field <code>string message = 1;</code>
* @return string
*/
public function getMessage()
{
return $this->message;
}
/**
* Generated from protobuf field <code>string message = 1;</code>
* @param string $var
* @return $this
*/
public function setMessage($var)
{
GPBUtil::checkString($var, True);
$this->message = $var;
return $this;
}
}

View File

@@ -0,0 +1,61 @@
<?php
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: protos/example.proto
namespace GRPC\Greeter;
use Google\Protobuf\Internal\GPBType;
use Google\Protobuf\Internal\GPBUtil;
use Google\Protobuf\RepeatedField;
/**
* The request message containing the user's name.
*
* Generated from protobuf message <code>helloworld.HelloRequest</code>
*/
class HelloRequest extends \Google\Protobuf\Internal\Message
{
/**
* Generated from protobuf field <code>string name = 1;</code>
*/
protected $name = '';
/**
* Constructor.
*
* @param array $data {
* Optional. Data for populating the Message object.
*
* @type string $name
* }
*/
public function __construct($data = NULL) {
\GRPC\GPBMetadata\Example::initOnce();
parent::__construct($data);
}
/**
* Generated from protobuf field <code>string name = 1;</code>
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* Generated from protobuf field <code>string name = 1;</code>
* @param string $var
* @return $this
*/
public function setName($var)
{
GPBUtil::checkString($var, True);
$this->name = $var;
return $this;
}
}

3
generated/README.md Normal file
View File

@@ -0,0 +1,3 @@
### Note to Developers
Only generated files are allowed in this directory.
Please do not add any other files here manually.

14
grpc-worker.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
use Siteworxpro\App\Grpc;
require __DIR__ . '/vendor/autoload.php';
try {
$server = new Grpc();
$server->start();
} catch (\Exception $e) {
echo $e->getMessage();
exit(1);
}

120
makefile Normal file
View File

@@ -0,0 +1,120 @@
# Makefile (enhanced)
SHELL := /bin/sh
.DEFAULT_GOAL := help
# Reusable vars
DOCKER := docker compose
COMPOSER_RUNTIME := composer-runtime
DEV_RUNTIME := dev-runtime
MIGRATION_CONTAINER := migration-container
PROTOC_GEN_DIR := ./protoc-gen-php-grpc-2025.1.5-darwin-arm64
PROTOC_GEN := $(PROTOC_GEN_DIR)/protoc-gen-php-grpc
PROTOC_URL := https://github.com/roadrunner-server/roadrunner/releases/download/v2025.1.5/protoc-gen-php-grpc-2025.1.5-darwin-arm64.tar.gz
COMPOSER := $(DOCKER) exec $(COMPOSER_RUNTIME) sh -c
DEV := $(DOCKER) exec $(DEV_RUNTIME) sh -c
# Colors
GREEN := \033[32m
YELLOW := \033[33m
RESET := \033[0m
# Align width for help display
HELP_COL_WIDTH := 26
# Help: auto-generate from targets with "##" comments
help: ## Show this help
@echo "Available commands:"
@awk -F':|##' '/^[a-zA-Z0-9._-]+:.*##/ {printf " %-$(HELP_COL_WIDTH)s - %s\n", $$1, $$3}' $(MAKEFILE_LIST) | sort
start: ## Start the development runtime container
@printf "$(GREEN)Starting $(DEV_RUNTIME)$(RESET)\n"
$(DOCKER) up $(DEV_RUNTIME) -d --no-recreate
sh: ## Open a shell in the development runtime container
@$(MAKE) start
$(DOCKER) exec $(DEV_RUNTIME) sh
run: ## Run the application server in the development runtime container
@$(MAKE) start
$(DEV) "rr serve"
stop: ## Stop and remove the development runtime container
@printf "$(YELLOW)Stopping all containers$(RESET)\n"
$(DOCKER) down
restart: ## Restart dev container (stop + start)
@$(MAKE) stop
@$(MAKE) start
rebuild: ## Rebuild containers (useful after Dockerfile changes)
@printf "$(YELLOW)Rebuilding containers$(RESET)\n"
$(DOCKER) build
$(DOCKER) up --force-recreate --build -d
ps: ## Show docker compose ps
$(DOCKER) ps
migrate: ## Run database migrations in the migration container
$(DOCKER) up $(MIGRATION_CONTAINER)
# Composer helpers
composer-install: ## Install PHP dependencies in the composer runtime container
$(COMPOSER) "composer install --no-interaction --prefer-dist --optimize-autoloader --ignore-platform-reqs"
composer-require: ## Require a PHP package in the composer runtime container (usage: make composer-require package=vendor/package)
ifndef package
$(error package variable is required: make composer-require package=vendor/package)
endif
$(COMPOSER) "composer require $(package) --ignore-platform-reqs"
composer-require-dev: ## Require a PHP package as dev in the composer runtime container (usage: make composer-require-dev package=vendor/package)
ifndef package
$(error package variable is required: make composer-require-dev package=vendor/package)
endif
$(COMPOSER) "composer require --dev $(package) --ignore-platform-reqs"
composer-update: ## Update PHP dependencies in the composer runtime container
$(COMPOSER) "composer update --no-interaction --prefer-dist --optimize-autoloader --ignore-platform-reqs"
enable-debug: ## Enable Xdebug in the development runtime container
@$(MAKE) start
$(DEV) "bin/xdebug.sh"
enable-coverage: ## Enable PCOV code coverage in the composer runtime container
@$(MAKE) start
$(COMPOSER) "bin/pcov.sh"
protoc: ## Generate PHP gRPC code from .proto files
@printf "$(GREEN)Setting up protoc-gen-php-grpc plugin$(RESET)\n"
@curl -LOs $(PROTOC_URL)
@tar -xzf protoc-gen-php-grpc-2025.1.5-darwin-arm64.tar.gz
@printf "$(GREEN)Generating PHP gRPC code from .proto files$(RESET)\n"
@protoc --plugin=./protoc-gen-php-grpc-2025.1.5-darwin-arm64/protoc-gen-php-grpc \
--php_out=./generated \
--php-grpc_out=./generated \
protos/example.proto
@printf "$(GREEN)Cleaning up protoc-gen-php-grpc plugin files$(RESET)\n"
@rm -rf $(PROTOC_GEN_DIR) protoc-gen-php-grpc-2025.1.5-darwin-arm64.tar.gz
# Developer tasks
lint: ## Run linting (phpcs/phpstan) in composer runtime
@$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate
$(COMPOSER) "composer run-script tests:lint || true"
$(COMPOSER) "composer run-script tests:phpstan || true"
fmt: ## Format code (php-cs-fixer)
@$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate
$(COMPOSER) "composer run-script tests:lint:fix"
test: ## Run test suite (phpunit)
$(COMPOSER) "composer run-script tests:unit || true"
# Convenience aliases
dev: run ## Alias for start
ci: composer-install test ## CI-like local flow
down: stop ## Alias for stop
up: start ## Alias for start
.PHONY: help start sh run stop restart rebuild ps logs migrate composer-install composer-require composer-require-dev composer-update enable-debug enable-coverage protoc lint fmt test check-lock dev ci

23
protos/example.proto Normal file
View File

@@ -0,0 +1,23 @@
syntax = "proto3";
option go_package = "proto/greeter";
option php_namespace = "GRPC\\Greeter";
option php_metadata_namespace = "GRPC\\GPBMetadata";
package helloworld;
// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings
message HelloReply {
string message = 1;
}

View File

@@ -5,10 +5,7 @@ use Siteworxpro\App\Api;
require __DIR__ . '/vendor/autoload.php'; require __DIR__ . '/vendor/autoload.php';
try { try {
// Instantiate the ExternalServer class
$server = new Api(); $server = new Api();
// Start the server
$server->startServer(); $server->startServer();
} catch (JsonException $e) { } catch (JsonException $e) {
echo $e->getMessage(); echo $e->getMessage();

View File

@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Annotations\Guards;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
readonly class Scope
{
public function __construct(
private array $scopes = []
) {
}
public function getScopes(): array
{
return $this->scopes;
}
}

View File

@@ -6,17 +6,20 @@ namespace Siteworxpro\App;
use League\Route\Http\Exception\MethodNotAllowedException; use League\Route\Http\Exception\MethodNotAllowedException;
use League\Route\Http\Exception\NotFoundException; use League\Route\Http\Exception\NotFoundException;
use League\Route\RouteGroup;
use League\Route\Router; use League\Route\Router;
use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Factory\Psr17Factory;
use Siteworxpro\App\Controllers\HealthcheckController; use Siteworxpro\App\Controllers\HealthcheckController;
use Siteworxpro\App\Controllers\IndexController; use Siteworxpro\App\Controllers\IndexController;
use Siteworxpro\App\Controllers\OpenApiController;
use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Middleware\CorsMiddleware; use Siteworxpro\App\Http\Middleware\CorsMiddleware;
use Siteworxpro\App\Http\Middleware\JwtMiddleware; use Siteworxpro\App\Http\Middleware\JwtMiddleware;
use Siteworxpro\App\Http\Middleware\ScopeMiddleware; use Siteworxpro\App\Http\Middleware\ScopeMiddleware;
use Siteworxpro\App\Http\Responses\NotFoundResponse;
use Siteworxpro\App\Http\Responses\ServerErrorResponse;
use Siteworxpro\App\Services\Facades\Config; use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Logger; use Siteworxpro\App\Services\Facades\Logger;
use Siteworxpro\HttpStatus\CodesEnum;
use Spiral\RoadRunner\Http\PSR7Worker; use Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\Worker; use Spiral\RoadRunner\Worker;
@@ -69,8 +72,14 @@ class Api
$this->router = new Router(); $this->router = new Router();
$this->router->get('/', IndexController::class . '::get'); $this->router->get('/', IndexController::class . '::get');
$this->router->post('/', IndexController::class . '::post');
$this->router->get('/healthz', HealthcheckController::class . '::get'); $this->router->get('/healthz', HealthcheckController::class . '::get');
$this->router->group('/.well-known', function (RouteGroup $router) {
$router->get('/swagger.yaml', OpenApiController::class . '::get');
$router->get('/swagger.json', OpenApiController::class . '::get');
});
$this->router->middleware(new CorsMiddleware()); $this->router->middleware(new CorsMiddleware());
$this->router->middleware(new JwtMiddleware()); $this->router->middleware(new JwtMiddleware());
$this->router->middleware(new ScopeMiddleware()); $this->router->middleware(new ScopeMiddleware());
@@ -104,28 +113,20 @@ class Api
$response = $this->router->handle($request); $response = $this->router->handle($request);
$this->worker->respond($response); $this->worker->respond($response);
} catch (MethodNotAllowedException | NotFoundException) { } catch (MethodNotAllowedException | NotFoundException) {
$uri = '';
if (isset($request)) {
$uri = $request->getUri()->getPath();
}
$this->worker->respond( $this->worker->respond(
JsonResponseFactory::createJsonResponse( JsonResponseFactory::createJsonResponse(new NotFoundResponse($uri))
['status_code' => 404, 'reason_phrase' => 'Not Found'],
CodesEnum::NOT_FOUND
)
); );
} catch (\Throwable $e) { } catch (\Throwable $e) {
Logger::error($e->getMessage()); Logger::error($e->getMessage());
Logger::error($e->getTraceAsString()); Logger::error($e->getTraceAsString());
$json = ['status_code' => 500, 'reason_phrase' => 'Server Error'];
if (Config::get("server.dev_mode")) {
$json = [
'status_code' => 500,
'reason_phrase' => 'Server Error',
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
];
}
$this->worker->respond( $this->worker->respond(
JsonResponseFactory::createJsonResponse($json, CodesEnum::INTERNAL_SERVER_ERROR) JsonResponseFactory::createJsonResponse(new ServerErrorResponse($e))
); );
} }
} }

View File

@@ -4,7 +4,7 @@ declare(ticks=1);
namespace Siteworxpro\App\Async; namespace Siteworxpro\App\Async;
use Siteworxpro\App\Annotations\Async\HandlesMessage; use Siteworxpro\App\Attributes\Async\HandlesMessage;
use Siteworxpro\App\Async\Messages\Message; use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Async\Queues\Queue; use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Services\Facades\Broker; use Siteworxpro\App\Services\Facades\Broker;

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\Async\Handlers; namespace Siteworxpro\App\Async\Handlers;
use Siteworxpro\App\Annotations\Async\HandlesMessage; use Siteworxpro\App\Attributes\Async\HandlesMessage;
use Siteworxpro\App\Async\Messages\Message; use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Async\Messages\SayHelloMessage; use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Services\Facades\Logger; use Siteworxpro\App\Services\Facades\Logger;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Siteworxpro\App\Annotations\Async; namespace Siteworxpro\App\Attributes\Async;
use Attribute; use Attribute;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Siteworxpro\App\Annotations\Events; namespace Siteworxpro\App\Attributes\Events;
use Attribute; use Attribute;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Siteworxpro\App\Annotations\Guards; namespace Siteworxpro\App\Attributes\Guards;
use Attribute; use Attribute;
use Siteworxpro\App\Services\Facades\Config; use Siteworxpro\App\Services\Facades\Config;
@@ -32,16 +32,6 @@ readonly class Jwt
) { ) {
} }
/**
* Get the required audience from configuration, ignoring any local override.
*
* @return string The globally configured audience or an empty string if not set.
*/
public function getRequiredAudience(): string
{
return Config::get('jwt.audience') ?? '';
}
/** /**
* Get the expected audience for validation. * Get the expected audience for validation.
* *

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\Guards;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
readonly class RequireAllScopes
{
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\Guards;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
readonly class Scope
{
/**
* @param array<int, string> $scopes the required scopes
* @param string $claim the claim to check for scopes
* @param string $separator the separator used to split scopes in the claim
*/
public function __construct(
private array $scopes = [],
private string $claim = 'scope',
private string $separator = ' '
) {
}
public function getScopes(): array
{
return $this->scopes;
}
public function getClaim(): string
{
return $this->claim;
}
public function getSeparator(): string
{
return $this->separator;
}
}

View File

@@ -8,6 +8,7 @@ use Ahc\Cli\Application;
use Siteworxpro\App\Cli\Commands\DemoCommand; use Siteworxpro\App\Cli\Commands\DemoCommand;
use Siteworxpro\App\Cli\Commands\Queue\Start; use Siteworxpro\App\Cli\Commands\Queue\Start;
use Siteworxpro\App\Cli\Commands\Queue\TestJob; use Siteworxpro\App\Cli\Commands\Queue\TestJob;
use Siteworxpro\App\Helpers\Version;
use Siteworxpro\App\Kernel; use Siteworxpro\App\Kernel;
use Siteworxpro\App\Services\Facades\Config; use Siteworxpro\App\Services\Facades\Config;
@@ -21,7 +22,7 @@ class App
public function __construct() public function __construct()
{ {
Kernel::boot(); Kernel::boot();
$this->app = new Application('Php-Template', Config::get('app.version') ?? 'dev-master'); $this->app = new Application('Php-Template', Version::VERSION);
$this->app->add(new DemoCommand()); $this->app->add(new DemoCommand());
$this->app->add(new Start()); $this->app->add(new Start());

View File

@@ -6,7 +6,9 @@ namespace Siteworxpro\App\Controllers;
use League\Route\Http\Exception\NotFoundException; use League\Route\Http\Exception\NotFoundException;
use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\ServerRequest;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Helpers\Version;
/** /**
* Class Controller * Class Controller
@@ -15,6 +17,18 @@ use Psr\Http\Message\ResponseInterface;
* *
* @package Siteworxpro\App\Controllers * @package Siteworxpro\App\Controllers
*/ */
#[OA\Info(
version: Version::VERSION,
description: "This is a template API built using Siteworxpro framework.",
title: "Siteworxpro Template API",
contact: new OA\Contact(
name: "Siteworxpro",
url: "https://www.siteworxpro.com",
email: "support@siteworxpro.com"
),
license: new OA\License('MIT', 'https://opensource.org/licenses/MIT')
)]
#[OA\Server(url: "https://localhost", description: "Local Server")]
abstract class Controller implements ControllerInterface abstract class Controller implements ControllerInterface
{ {
/** /**

View File

@@ -8,9 +8,11 @@ use Illuminate\Database\PostgresConnection;
use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\GenericResponse;
use Siteworxpro\App\Models\Model; use Siteworxpro\App\Models\Model;
use Siteworxpro\App\Services\Facades\Redis; use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum; use Siteworxpro\HttpStatus\CodesEnum;
use OpenApi\Attributes as OA;
/** /**
* Class HealthcheckController * Class HealthcheckController
@@ -22,8 +24,13 @@ use Siteworxpro\HttpStatus\CodesEnum;
class HealthcheckController extends Controller class HealthcheckController extends Controller
{ {
/** /**
* Handles the GET request for health check.
*
* @throws \JsonException * @throws \JsonException
*/ */
#[OA\Get(path: '/healthz', tags: ['Healthcheck'])]
#[OA\Response(response: '200', description: 'Healthcheck OK')]
#[OA\Response(response: '503', description: 'Healthcheck Failed')]
public function get(ServerRequest $request): ResponseInterface public function get(ServerRequest $request): ResponseInterface
{ {
try { try {
@@ -47,7 +54,7 @@ class HealthcheckController extends Controller
} }
return JsonResponseFactory::createJsonResponse( return JsonResponseFactory::createJsonResponse(
['status_code' => 200, 'message' => 'Healthcheck OK'] new GenericResponse('Healthcheck OK', CodesEnum::OK->value)
); );
} }
} }

View File

@@ -6,8 +6,12 @@ namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Annotations\Guards; use Siteworxpro\App\Attributes\Guards;
use Siteworxpro\App\Docs\TokenSecurity;
use Siteworxpro\App\Docs\UnauthorizedResponse;
use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\JsonResponseFactory;
use OpenApi\Attributes as OA;
use Siteworxpro\App\Http\Responses\GenericResponse;
/** /**
* Class IndexController * Class IndexController
@@ -22,19 +26,36 @@ class IndexController extends Controller
* @throws \JsonException * @throws \JsonException
*/ */
#[Guards\Jwt] #[Guards\Jwt]
#[Guards\Scope(['get.index'])] #[Guards\Scope(['get.index', 'status.check'])]
#[Guards\RequireAllScopes]
#[OA\Get(path: '/', security: [new TokenSecurity()], tags: ['Examples'])]
#[OA\Response(
response: '200',
description: 'An Example Response',
content: new OA\JsonContent(ref: '#/components/schemas/GenericResponse')
)]
#[UnauthorizedResponse]
public function get(ServerRequest $request): ResponseInterface public function get(ServerRequest $request): ResponseInterface
{ {
return JsonResponseFactory::createJsonResponse(['status_code' => 200, 'message' => 'Server is running']); return JsonResponseFactory::createJsonResponse(new GenericResponse('Server is running'));
} }
/** /**
* Handles the POST request for the index route.
*
* @throws \JsonException * @throws \JsonException
*/ */
#[Guards\Jwt] #[Guards\Jwt]
#[Guards\Scope(['post.index'])] #[Guards\Scope(['post.index'])]
#[OA\Post(path: '/', security: [new TokenSecurity()], tags: ['Examples'])]
#[OA\Response(
response: '200',
description: 'An Example Response',
content: new OA\JsonContent(ref: '#/components/schemas/GenericResponse')
)]
#[UnauthorizedResponse]
public function post(ServerRequest $request): ResponseInterface public function post(ServerRequest $request): ResponseInterface
{ {
return JsonResponseFactory::createJsonResponse(['status_code' => 200, 'message' => 'Server is running']); return JsonResponseFactory::createJsonResponse(new GenericResponse('POST request received'));
} }
} }

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use OpenApi\Generator;
use Psr\Http\Message\ResponseInterface;
class OpenApiController extends Controller
{
/**
* Handles the GET request to generate and return the OpenAPI specification.
*
* @param ServerRequest $request
* @return ResponseInterface
*/
public function get(ServerRequest $request): ResponseInterface
{
$openapi = new Generator()->generate([
__DIR__ . '/../Controllers',
__DIR__ . '/../Models',
__DIR__ . '/../Http/Responses',
]);
$response = new Response();
if (
$request->getHeaderLine('Accept') === 'application/json' ||
str_contains($request->getUri()->getPath(), '.json')
) {
$response->getBody()->write($openapi->toJson());
return $response->withHeader('Content-Type', 'application/json');
}
$response->getBody()->write($openapi->toYaml());
return $response->withHeader('Content-Type', 'application/x-yaml');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Siteworxpro\App\Docs;
use OpenApi\Attributes as OA;
class TokenSecurity extends OA\SecurityScheme
{
public function __construct()
{
parent::__construct(
securityScheme: 'bearerAuth',
type: 'http',
description: 'JWT based authentication using Bearer tokens.',
bearerFormat: 'JWT',
scheme: 'bearer'
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Siteworxpro\App\Docs;
use OpenApi\Attributes as OA;
#[\Attribute]
class UnauthorizedResponse extends OA\Response
{
public function __construct()
{
parent::__construct(
response: '401',
description: 'Unauthorized - Authentication is required and has failed or has not yet been provided.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
properties: [
new OA\Property(property: 'status_code', type: 'integer', example: 401),
new OA\Property(property: 'message', type: 'string', example: 'Unauthorized'),
]
)
)
);
}
}

View File

@@ -7,7 +7,10 @@ namespace Siteworxpro\App\Events;
use Illuminate\Contracts\Events\Dispatcher as DispatcherContract; use Illuminate\Contracts\Events\Dispatcher as DispatcherContract;
use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Siteworxpro\App\Annotations\Events\ListensFor; use Siteworxpro\App\Attributes\Events\ListensFor;
use function React\Async\await;
use function React\Async\coroutine;
/** /**
* Class Dispatcher * Class Dispatcher
@@ -29,6 +32,8 @@ class Dispatcher implements DispatcherContract, Arrayable
*/ */
private Collection $pushed; private Collection $pushed;
private array $subscribers = [];
/** /**
* @var string LISTENERS_NAMESPACE The namespace where listeners are located * @var string LISTENERS_NAMESPACE The namespace where listeners are located
*/ */
@@ -40,6 +45,16 @@ class Dispatcher implements DispatcherContract, Arrayable
$this->registerListeners(); $this->registerListeners();
} }
/**
* @throws \Throwable
*/
public function __destruct()
{
foreach ($this->pushed as $event => $payload) {
$this->dispatch($event, $payload);
}
}
/** /**
* Register event listeners based on the ListensFor attribute. * Register event listeners based on the ListensFor attribute.
* *
@@ -99,7 +114,7 @@ class Dispatcher implements DispatcherContract, Arrayable
*/ */
public function subscribe($subscriber): void public function subscribe($subscriber): void
{ {
$this->listeners = array_merge($this->listeners, (array) $subscriber); $this->subscribers[] = $subscriber;
} }
/** /**
@@ -108,6 +123,7 @@ class Dispatcher implements DispatcherContract, Arrayable
* @param $event * @param $event
* @param array $payload * @param array $payload
* @return array|null * @return array|null
* @throws \Throwable
*/ */
public function until($event, $payload = []): array|null public function until($event, $payload = []): array|null
{ {
@@ -121,6 +137,7 @@ class Dispatcher implements DispatcherContract, Arrayable
* @param array $payload * @param array $payload
* @param bool $halt * @param bool $halt
* @return array|null * @return array|null
* @throws \Throwable
*/ */
public function dispatch($event, $payload = [], $halt = false): array|null public function dispatch($event, $payload = [], $halt = false): array|null
{ {
@@ -130,23 +147,46 @@ class Dispatcher implements DispatcherContract, Arrayable
$eventClass = $event; $eventClass = $event;
} }
// Handle subscribers as a coroutine
$promise = coroutine(function () use ($event, $payload, $halt, $eventClass, &$responses) {
foreach ($this->subscribers as $subscriber) {
if (method_exists($subscriber, 'handle')) {
$response = $subscriber->handle($event, $payload);
$responses[$eventClass] = $response;
if ($halt && $response !== null) {
return $responses;
}
}
}
return null;
});
$listeners = $this->listeners[$eventClass] ?? null; $listeners = $this->listeners[$eventClass] ?? null;
// If no listeners, just await the subscriber promise
if ($listeners === null) { if ($listeners === null) {
return null; return await($promise);
} }
$responses = []; $responses = [];
foreach ($listeners as $listener) { foreach ($listeners as $listener) {
$response = $listener($event, $payload); $response = $listener($event, $payload);
$responses[] = $response; $responses[$eventClass] = $response;
if ($halt && $response !== null) { if ($halt && $response !== null) {
return $response; return $response;
} }
} }
// Await the subscriber promise and merge responses
$promiseResponses = await($promise);
if (is_array($promiseResponses)) {
$responses = array_merge($responses, $promiseResponses);
}
return $responses; return $responses;
} }
@@ -167,6 +207,7 @@ class Dispatcher implements DispatcherContract, Arrayable
* *
* @param $event * @param $event
* @return void * @return void
* @throws \Throwable
*/ */
public function flush($event): void public function flush($event): void
{ {

View File

@@ -6,7 +6,7 @@ namespace Siteworxpro\App\Events\Listeners\Database;
use Illuminate\Database\Events\ConnectionEstablished; use Illuminate\Database\Events\ConnectionEstablished;
use Illuminate\Database\Events\ConnectionEvent; use Illuminate\Database\Events\ConnectionEvent;
use Siteworxpro\App\Annotations\Events\ListensFor; use Siteworxpro\App\Attributes\Events\ListensFor;
use Siteworxpro\App\Events\Listeners\Listener; use Siteworxpro\App\Events\Listeners\Listener;
use Siteworxpro\App\Services\Facades\Logger; use Siteworxpro\App\Services\Facades\Logger;
@@ -18,12 +18,15 @@ use Siteworxpro\App\Services\Facades\Logger;
class Connected extends Listener class Connected extends Listener
{ {
/** /**
* @param ConnectionEvent $event * @param mixed $event
* @param array $payload * @param array $payload
* @return null * @return null
*/ */
public function __invoke($event, array $payload = []): null public function __invoke(mixed $event, array $payload = []): null
{ {
if (!($event instanceof ConnectionEvent)) {
throw new \TypeError("Invalid event type passed to listener " . static::class);
}
Logger::info("Database connection event", [get_class($event), $event->connectionName]); Logger::info("Database connection event", [get_class($event), $event->connectionName]);

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Subscribers;
use Illuminate\Contracts\Support\Arrayable;
abstract class Subscriber implements SubscriberInterface, Arrayable
{
public function toArray(): array
{
return get_object_vars($this);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Subscribers;
interface SubscriberInterface
{
public function handle(string $eventName, mixed $payload): mixed;
}

47
src/Grpc.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App;
use GRPC\Greeter\GreeterInterface;
use Siteworxpro\App\GrpcHandlers\GreeterHandler;
use Siteworxpro\App\Services\Facades\Config;
use Spiral\RoadRunner\GRPC\Invoker;
use Spiral\RoadRunner\GRPC\Server;
use Spiral\RoadRunner\Worker;
/**
* Class Grpc
*
* starts a gRPC server using RoadRunner
*
* @package Siteworxpro\App
*/
class Grpc
{
/**
* @throws \ReflectionException
*/
public function __construct()
{
Kernel::boot();
}
/**
* Starts the gRPC server
*
* @return int
*/
public function start(): int
{
$server = new Server(new Invoker(), [
'debug' => (bool) Config::get('app.debug'),
]);
$server->registerService(GreeterInterface::class, new GreeterHandler());
$server->serve(Worker::create());
return 0;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\GrpcHandlers;
use GRPC\Greeter\GreeterInterface;
use GRPC\Greeter\HelloReply;
use GRPC\Greeter\HelloRequest;
use Spiral\RoadRunner\GRPC;
class GreeterHandler implements GreeterInterface
{
public function SayHello(GRPC\ContextInterface $ctx, HelloRequest $in): HelloReply // phpcs:ignore
{
$reply = new HelloReply();
$reply->setMessage('Hello ' . $in->getName());
return $reply;
}
}

10
src/Helpers/Version.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Helpers;
class Version
{
public const string VERSION = 'dev-master';
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\Http; namespace Siteworxpro\App\Http;
use Illuminate\Contracts\Support\Arrayable;
use Nyholm\Psr7\Response; use Nyholm\Psr7\Response;
use Siteworxpro\HttpStatus\CodesEnum; use Siteworxpro\HttpStatus\CodesEnum;
@@ -17,13 +18,19 @@ class JsonResponseFactory
/** /**
* Create a JSON response with the given data and status code. * Create a JSON response with the given data and status code.
* *
* @param array $data The data to include in the response. * @param array|Arrayable $data The data to include in the response.
* @param CodesEnum $statusCode The HTTP status code for the response. * @param CodesEnum $statusCode The HTTP status code for the response.
* @return Response The JSON response. * @return Response The JSON response.
* @throws \JsonException * @throws \JsonException
*/ */
public static function createJsonResponse(array $data, CodesEnum $statusCode = CodesEnum::OK): Response public static function createJsonResponse(
{ array|Arrayable $data,
CodesEnum $statusCode = CodesEnum::OK
): Response {
if ($data instanceof Arrayable) {
$data = $data->toArray();
}
return new Response( return new Response(
status: $statusCode->value, status: $statusCode->value,
headers: [ headers: [

View File

@@ -6,8 +6,10 @@ namespace Siteworxpro\App\Http\Middleware;
use Carbon\Carbon; use Carbon\Carbon;
use Carbon\WrapperClock; use Carbon\WrapperClock;
use GuzzleHttp\Exception\GuzzleException;
use Lcobucci\JWT\JwtFacade; use Lcobucci\JWT\JwtFacade;
use Lcobucci\JWT\Signer\Hmac\Sha256 as Hmac256; use Lcobucci\JWT\Signer\Hmac\Sha256 as Hmac256;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\InvalidTokenStructure; use Lcobucci\JWT\Token\InvalidTokenStructure;
@@ -21,10 +23,12 @@ use League\Route\Dispatcher;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\App\Annotations\Guards\Jwt; use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Controllers\Controller; use Siteworxpro\App\Controllers\Controller;
use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Services\Facades\Config; use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Guzzle;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum; use Siteworxpro\HttpStatus\CodesEnum;
/** /**
@@ -103,7 +107,7 @@ class JwtMiddleware extends Middleware
/** @var Jwt $jwtInstance */ /** @var Jwt $jwtInstance */
$jwtInstance = $attribute->newInstance(); $jwtInstance = $attribute->newInstance();
if ($jwtInstance->getRequiredAudience() !== '') { if ($jwtInstance->getAudience() !== '') {
$requiredAudience = $jwtInstance->getAudience(); $requiredAudience = $jwtInstance->getAudience();
} }
@@ -114,7 +118,7 @@ class JwtMiddleware extends Middleware
// Parse and validate the token with signature, time, issuer and audience constraints. // Parse and validate the token with signature, time, issuer and audience constraints.
$jwt = new JwtFacade()->parse( $jwt = new JwtFacade()->parse(
$token, $token,
$this->getSignedWith(), $this->getSignedWith($token),
Config::get('jwt.strict_validation') ? Config::get('jwt.strict_validation') ?
new StrictValidAt(new WrapperClock(Carbon::now())) : new StrictValidAt(new WrapperClock(Carbon::now())) :
new LooseValidAt(new WrapperClock(Carbon::now())), new LooseValidAt(new WrapperClock(Carbon::now())),
@@ -129,16 +133,21 @@ class JwtMiddleware extends Middleware
} }
return JsonResponseFactory::createJsonResponse([ return JsonResponseFactory::createJsonResponse([
'status_code' => 401, 'status_code' => CodesEnum::UNAUTHORIZED->value,
'message' => 'Unauthorized: Invalid token', 'message' => 'Unauthorized: Invalid token',
'errors' => $violations 'errors' => $violations
], CodesEnum::UNAUTHORIZED); ], CodesEnum::UNAUTHORIZED);
} catch (InvalidTokenStructure) { } catch (InvalidTokenStructure) {
// Token could not be parsed due to malformed structure. // Token could not be parsed due to malformed structure.
return JsonResponseFactory::createJsonResponse([ return JsonResponseFactory::createJsonResponse([
'status_code' => 401, 'status_code' => CodesEnum::UNAUTHORIZED->value,
'message' => 'Unauthorized: Invalid token', 'message' => 'Unauthorized: Invalid token',
], CodesEnum::UNAUTHORIZED); ], CodesEnum::UNAUTHORIZED);
} catch (GuzzleException | \RuntimeException) {
return JsonResponseFactory::createJsonResponse([
'status_code' => CodesEnum::INTERNAL_SERVER_ERROR->value,
'message' => 'Token validation service unavailable or unknown error',
], CodesEnum::INTERNAL_SERVER_ERROR);
} }
// Expose all token claims as request attributes for downstream consumers. // Expose all token claims as request attributes for downstream consumers.
@@ -161,20 +170,30 @@ class JwtMiddleware extends Middleware
* @return SignedWith Signature constraint used during JWT parsing. * @return SignedWith Signature constraint used during JWT parsing.
* *
* @throws \RuntimeException When no signing key is configured. * @throws \RuntimeException When no signing key is configured.
* @throws \JsonException
*/ */
private function getSignedWith(): SignedWith private function getSignedWith(string $token): SignedWith
{ {
$key = Config::get('jwt.signing_key'); $keyConfig = Config::get('jwt.signing_key');
if ($key === null) { if ($keyConfig === null) {
throw new \RuntimeException('JWT signing key is not configured.'); throw new \RuntimeException('JWT signing key is not configured.');
} }
// Load key either from file or raw text. // file:// path to key
if (str_starts_with($key, 'file://')) { if (str_starts_with($keyConfig, 'file://')) {
$key = InMemory::file(substr($key, 7)); $key = InMemory::file(substr($keyConfig, 7));
// openid jwks url
} elseif (str_contains($keyConfig, '.well-known/')) {
$jwt = explode('.', $token);
if (count($jwt) !== 3) {
throw new InvalidTokenStructure('Invalid JWT structure for JWKS key retrieval.');
}
$header = json_decode(base64_decode($jwt[0]), true, 512, JSON_THROW_ON_ERROR);
$keyId = $header['kid'] ?? '0'; // Default to '0' if no kid present
$key = $this->getJwksKey($keyConfig, $keyId);
} else { } else {
$key = InMemory::plainText($key); $key = InMemory::plainText($keyConfig);
} }
// Heuristic: if PEM public key content is detected, use RSA; otherwise use HMAC. // Heuristic: if PEM public key content is detected, use RSA; otherwise use HMAC.
@@ -184,4 +203,120 @@ class JwtMiddleware extends Middleware
return new SignedWith(new Hmac256(), $key); return new SignedWith(new Hmac256(), $key);
} }
private function getJwksKey(string $url, string $keyId): Key
{
$cached = Redis::get('jwks_key_' . $keyId);
if ($cached !== null) {
return InMemory::plainText($cached);
}
$openIdConfig = Guzzle::get($url);
$body = json_decode($openIdConfig->getBody()->getContents(), true, JSON_THROW_ON_ERROR);
$jwksUri = $body['jwks_uri'] ?? '';
if (empty($jwksUri)) {
throw new \RuntimeException('JWKS URI not found in OpenID configuration.');
}
$jwksResponse = Guzzle::get($jwksUri);
$jwksBody = json_decode(
$jwksResponse->getBody()->getContents(),
true,
JSON_THROW_ON_ERROR
);
// For simplicity, we take the first key in the JWKS.
$firstKey = array_filter(
$jwksBody['keys'],
fn($key) => $key['kid'] === $keyId
)[0] ?? $jwksBody['keys'][0] ?? null;
if (empty($firstKey)) {
throw new \RuntimeException('No matching key found in JWKS for key ID: ' . $keyId);
}
$n = $firstKey['n'];
$e = $firstKey['e'];
$publicKeyPem = "-----BEGIN PUBLIC KEY-----\n" .
chunk_split(base64_encode($this->convertJwkToPem($n, $e)), 64) .
"-----END PUBLIC KEY-----\n";
Redis::set('jwks_key_' . $keyId, $publicKeyPem, 'EX', 3600);
return InMemory::plainText($publicKeyPem);
}
/**
* Build a DER-encoded SubjectPublicKeyInfo from JWK 'n' and 'e'.
* Returns raw DER bytes; caller base64-encodes and wraps with PEM headers.
*/
private function convertJwkToPem(string $n, string $e): string
{
$modulus = $this->base64UrlDecode($n);
$exponent = $this->base64UrlDecode($e);
$derN = $this->derEncodeInteger($modulus);
$derE = $this->derEncodeInteger($exponent);
// RSAPublicKey (PKCS#1): SEQUENCE { n INTEGER, e INTEGER }
$rsaPublicKey = $this->derEncodeSequence($derN . $derE);
// AlgorithmIdentifier for rsaEncryption: 1.2.840.113549.1.1.1 with NULL
$algId = hex2bin('300d06092a864886f70d0101010500');
// SubjectPublicKey (SPKI) BIT STRING, 0 unused bits + RSAPublicKey
$subjectPublicKey = $this->derEncodeBitString($rsaPublicKey);
// SubjectPublicKeyInfo: SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }
return $this->derEncodeSequence($algId . $subjectPublicKey);
}
private function base64UrlDecode(string $data): string
{
$data = strtr($data, '-_', '+/');
$pad = strlen($data) % 4;
if ($pad) {
$data .= str_repeat('=', 4 - $pad);
}
return base64_decode($data);
}
private function derEncodeLength(int $len): string
{
if ($len < 0x80) {
return chr($len);
}
$bytes = '';
while ($len > 0) {
$bytes = chr($len & 0xFF) . $bytes;
$len >>= 8;
}
return chr(0x80 | strlen($bytes)) . $bytes;
}
private function derEncodeInteger(string $bytes): string
{
// Remove leading zeroes
$bytes = ltrim($bytes, "\x00");
if ($bytes === '') {
$bytes = "\x00";
}
// Ensure positive INTEGER (prepend 0x00 if MSB set)
if ((ord($bytes[0]) & 0x80) !== 0) {
$bytes = "\x00" . $bytes;
}
return "\x02" . $this->derEncodeLength(strlen($bytes)) . $bytes;
}
private function derEncodeSequence(string $bytes): string
{
return "\x30" . $this->derEncodeLength(strlen($bytes)) . $bytes;
}
private function derEncodeBitString(string $bytes): string
{
// 0 unused bits + data
$payload = "\x00" . $bytes;
return "\x03" . $this->derEncodeLength(strlen($payload)) . $payload;
}
} }

View File

@@ -8,7 +8,8 @@ use League\Route\Dispatcher;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\App\Annotations\Guards\Scope; use Siteworxpro\App\Attributes\Guards\RequireAllScopes;
use Siteworxpro\App\Attributes\Guards\Scope;
use Siteworxpro\App\Controllers\Controller; use Siteworxpro\App\Controllers\Controller;
use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\HttpStatus\CodesEnum; use Siteworxpro\HttpStatus\CodesEnum;
@@ -57,26 +58,49 @@ class ScopeMiddleware extends Middleware
// Ensure the controller exists and the method is defined before reflecting. // Ensure the controller exists and the method is defined before reflecting.
if (class_exists($class::class)) { if (class_exists($class::class)) {
$reflectionClass = new \ReflectionClass($class); $reflectionClass = new \ReflectionClass($class);
if ($reflectionClass->hasMethod($method)) { if ($reflectionClass->hasMethod($method)) {
$reflectionMethod = $reflectionClass->getMethod($method); $reflectionMethod = $reflectionClass->getMethod($method);
// Fetch all Scope attributes declared on the method. // Fetch all Scope attributes declared on the method.
$attributes = $reflectionMethod->getAttributes(Scope::class); $attributes = $reflectionMethod->getAttributes(Scope::class);
$requireAllAttributes = $reflectionMethod->getAttributes(RequireAllScopes::class);
if (empty($attributes)) {
// No scope attributes; delegate to the next handler.
return $handler->handle($request);
}
$requiredScopes = [];
$userScopes = [];
$requireAll = false;
foreach ($attributes as $attribute) { foreach ($attributes as $attribute) {
/** @var Scope $scopeInstance Concrete Scope attribute instance. */ /** @var Scope $scopeInstance Concrete Scope attribute instance. */
$scopeInstance = $attribute->newInstance(); $scopeInstance = $attribute->newInstance();
$requiredScopes = $scopeInstance->getScopes(); $requiredScopes = array_merge($requiredScopes, $scopeInstance->getScopes());
// Retrieve user scopes from the request (defaults to an empty array). // If any attribute requires all scopes, set the flag.
$userScopes = $request->getAttribute('scopes', []); $requireAll = $requireAll || !empty($requireAllAttributes);
$scopes = $request->getAttribute($scopeInstance->getClaim());
if (!is_array($scopes)) {
// If user scopes are not an array, treat as no scopes provided.
$scopes = explode($scopeInstance->getSeparator(), (string) $scopes);
}
$userScopes = array_merge(
$userScopes,
$scopes
);
}
$userScopes = array_unique($userScopes);
// Deny if any required scope is missing from the user's scopes. // Deny if any required scope is missing from the user's scopes.
if ( if (
array_any( (!$requireAll && array_intersect($userScopes, $requiredScopes) === []) ||
$requiredScopes, ($requireAll && array_diff($requiredScopes, $userScopes) !== [])
fn($requiredScope) => !in_array($requiredScope, $userScopes, true)
)
) { ) {
return JsonResponseFactory::createJsonResponse([ return JsonResponseFactory::createJsonResponse([
'error' => 'insufficient_scope', 'error' => 'insufficient_scope',
@@ -86,7 +110,6 @@ class ScopeMiddleware extends Middleware
} }
} }
} }
}
// All checks passed; continue down the middleware pipeline. // All checks passed; continue down the middleware pipeline.
return $handler->handle($request); return $handler->handle($request);

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Http\Responses;
use Illuminate\Contracts\Support\Arrayable;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'GenericResponse',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Operation completed successfully.'),
new OA\Property(property: 'status_code', type: 'integer', example: 200),
]
)]
readonly class GenericResponse implements Arrayable
{
public function __construct(
private string $message = '',
private int $statusCode = 200
) {
}
public function toArray(): array
{
return [
'message' => $this->message,
'status_code' => $this->statusCode,
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Siteworxpro\App\Http\Responses;
use Illuminate\Contracts\Support\Arrayable;
use Siteworxpro\HttpStatus\CodesEnum;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'NotFoundResponse',
properties: [
new OA\Property(
property: 'message',
type: 'string',
example: 'The requested resource /api/resource was not found.'
),
new OA\Property(property: 'status_code', type: 'integer', example: 404),
new OA\Property(
property: 'context',
description: 'Additional context about the not found error.',
type: 'object',
example: '{}'
),
]
)]
readonly class NotFoundResponse implements Arrayable
{
public function __construct(private string $uri, private array $context = [])
{
}
public function toArray(): array
{
return [
'status_code' => CodesEnum::NOT_FOUND->value,
'message' => 'The requested resource ' . $this->uri . ' was not found.',
'context' => $this->context,
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Siteworxpro\App\Http\Responses;
use Illuminate\Contracts\Support\Arrayable;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\HttpStatus\CodesEnum;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'ServerErrorResponse',
properties: array(
new OA\Property(property: 'message', type: 'string', example: 'An internal server error occurred.'),
new OA\Property(property: 'status_code', type: 'integer', example: 500),
new OA\Property(
property: 'file',
type: 'string',
example: '/var/www/html/app/Http/Controllers/ExampleController.php'
),
new OA\Property(property: 'line', type: 'integer', example: 42),
new OA\Property(
property: 'trace',
type: 'array',
items: new OA\Items(type: 'string'),
)
)
)]
readonly class ServerErrorResponse implements Arrayable
{
public function __construct(private \Throwable $e, private array $context = [])
{
}
public function toArray(): array
{
if (Config::get('app.dev_mode')) {
return [
'status_code' => $this->e->getCode() != 0 ?
$this->e->getCode() :
CodesEnum::INTERNAL_SERVER_ERROR->value,
'message' => $this->e->getMessage(),
'file' => $this->e->getFile(),
'line' => $this->e->getLine(),
'trace' => $this->e->getTrace(),
'context' => $this->context,
];
}
return [
'status_code' => $this->e->getCode() != 0 ?
$this->e->getCode() :
CodesEnum::INTERNAL_SERVER_ERROR->value,
'message' => 'An internal server error occurred.',
];
}
}

View File

@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace Siteworxpro\App\Models; namespace Siteworxpro\App\Models;
use Carbon\Carbon; use Carbon\Carbon;
use OpenApi\Attributes as OA;
use Siteworxpro\App\Helpers\Ulid;
/** /**
* Class User * Class User
* *
* @property string $id * @property-read string $id
* @property string $first_name * @property string $first_name
* @property string $last_name * @property string $last_name
* @property string $email * @property string $email
@@ -19,6 +21,23 @@ use Carbon\Carbon;
* @property-read string $full_name * @property-read string $full_name
* @property-read string $formatted_email * @property-read string $formatted_email
*/ */
#[OA\Schema(
schema: "User",
properties: [
new OA\Property(
property: "id",
description: "Unique identifier for the user",
type: "string",
format: "ulid",
readOnly: true,
example: '01KBD5WPZKYD77BYM2QD9NKG99'
),
new OA\Property(property: "first_name", type: "string"),
new OA\Property(property: "last_name", type: "string"),
new OA\Property(property: "email", type: "string", format: "email"),
new OA\Property(property: "created_at", type: "string", format: "date-time"),
]
)]
class User extends Model class User extends Model
{ {
protected $casts = [ protected $casts = [
@@ -36,6 +55,12 @@ class User extends Model
'password', 'password',
]; ];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->attributes['id'] = $this->attributes['id'] ?? Ulid::generate();
}
public function getFullNameAttribute(): string public function getFullNameAttribute(): string
{ {
return "$this->first_name $this->last_name"; return "$this->first_name $this->last_name";

View File

@@ -14,6 +14,7 @@ use Siteworxpro\App\Services\Facade;
* It extends the Facade class from the Illuminate\Support\Facades namespace. * It extends the Facade class from the Illuminate\Support\Facades namespace.
* *
* @method static array | bool | string | int | null get(string $key) Retrieve the configuration value for the given key. // @codingStandardsIgnoreStart * @method static array | bool | string | int | null get(string $key) Retrieve the configuration value for the given key. // @codingStandardsIgnoreStart
* @method static void set(string $key, mixed $value) Set the configuration value for the given key. // @codingStandardsIgnoreEnd
* *
* @package Siteworx\App\Facades * @package Siteworx\App\Facades
*/ */

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use GuzzleHttp\Client;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\Response;
use Siteworxpro\App\Services\Facade;
/**
* @method static Response get(string $uri, array $options = [])
* @method static Response post(string $uri, array $options = [])
* @method static Response put(string $uri, array $options = [])
* @method static Response delete(string $uri, array $options = [])
* @method static Response patch(string $uri, array $options = [])
* @method static Response head(string $uri, array $options = [])
* @method static PromiseInterface sendAsync(\Psr\Http\Message\RequestInterface $request, array $options = [])
* @method static PromiseInterface requestAsync(string $method, string $uri, array $options = [])
*/
class Guzzle extends Facade
{
protected static function getFacadeAccessor(): string
{
return Client::class;
}
}

View File

@@ -6,6 +6,7 @@ namespace Siteworxpro\App\Services\ServiceProviders;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Siteworxpro\App\Log\Logger; use Siteworxpro\App\Log\Logger;
use Siteworxpro\App\Services\Facades\Config;
/** /**
* Class LoggerServiceProvider * Class LoggerServiceProvider
@@ -17,7 +18,7 @@ class LoggerServiceProvider extends ServiceProvider
public function register(): void public function register(): void
{ {
$this->app->singleton(Logger::class, function () { $this->app->singleton(Logger::class, function () {
return new Logger(); return new Logger(Config::get('app.log_level'));
}); });
} }
} }

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Attributes\Guards;
use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\Tests\Unit;
class JwtTest extends Unit
{
public function testGetsClassFromConfig(): void
{
Config::set('jwt.issuer', 'default-issuer');
Config::set('jwt.audience', 'default-audience');
$reflection = new \ReflectionClass(TestClass::class);
$attributes = $reflection->getAttributes(Jwt::class);
$this->assertCount(1, $attributes);
/** @var Jwt $instance */
$instance = $attributes[0]->newInstance();
$this->assertEquals('default-audience', $instance->getAudience());
$this->assertEquals('default-issuer', $instance->getIssuer());
}
public function testGetsClassFromCustom(): void
{
$reflection = new \ReflectionClass(TestClassSpecific::class);
$attributes = $reflection->getAttributes(Jwt::class);
$this->assertCount(1, $attributes);
/** @var Jwt $instance */
$instance = $attributes[0]->newInstance();
$this->assertEquals('custom-audience', $instance->getAudience());
$this->assertEquals('custom-issuer', $instance->getIssuer());
}
}
#[Jwt]
class TestClass // @codingStandardsIgnoreLine
{
}
#[Jwt('custom-issuer', 'custom-audience')]
class TestClassSpecific // @codingStandardsIgnoreLine
{
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Attributes\Guards;
use Siteworxpro\App\Attributes\Guards\Scope;
use Siteworxpro\Tests\Unit;
class ScopeTest extends Unit
{
public function testGetsClassSingle(): void
{
$reflection = new \ReflectionClass(TestClassSingle::class);
$attributes = $reflection->getAttributes(Scope::class);
$this->assertCount(1, $attributes);
/** @var Scope $instance */
$instance = $attributes[0]->newInstance();
$this->assertEquals(['read:users'], $instance->getScopes());
}
public function testGetsClassFromCustom(): void
{
$reflection = new \ReflectionClass(TestClassMultiple::class);
$attributes = $reflection->getAttributes(Scope::class);
$this->assertCount(1, $attributes);
/** @var Scope $instance */
$instance = $attributes[0]->newInstance();
$this->assertEquals(['read:users', 'write:users'], $instance->getScopes());
}
}
#[Scope(['read:users', 'write:users'])]
class TestClassMultiple // @codingStandardsIgnoreLine
{
}
#[Scope(['read:users'])]
class TestClassSingle // @codingStandardsIgnoreLine
{
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Attributes;
use Siteworxpro\App\Attributes\Async\HandlesMessage;
use Siteworxpro\Tests\Unit;
class HandlesMessageTest extends Unit
{
public function testGetsClass(): void
{
$class = new #[HandlesMessage('Siteworxpro\Tests\Attributes\TestClass')] class {
};
$reflection = new \ReflectionClass($class);
$attributes = $reflection->getAttributes(HandlesMessage::class);
$this->assertCount(1, $attributes);
/** @var HandlesMessage $instance */
$instance = $attributes[0]->newInstance();
$this->assertEquals('Siteworxpro\Tests\Attributes\TestClass', $instance->getMessageClass());
}
}

View File

@@ -20,6 +20,21 @@ class IndexControllerTest extends AbstractController
$response = $controller->get($this->getMockRequest()); $response = $controller->get($this->getMockRequest());
$this->assertEquals(200, $response->getStatusCode()); $this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('{"status_code":200,"message":"Server is running"}', (string)$response->getBody()); $this->assertEquals('{"message":"Server is running","status_code":200}', (string)$response->getBody());
}
/**
* @throws \JsonException
*/
public function testPost(): void
{
$this->assertTrue(true);
$controller = new IndexController();
$response = $controller->post($this->getMockRequest());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('{"message":"POST request received","status_code":200}', (string)$response->getBody());
} }
} }

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Controllers;
use Siteworxpro\App\Controllers\OpenApiController;
class OpenApiControllerTest extends ControllerTest
{
public function testBuildsYaml(): void
{
$request = $this->getMockRequest('/.well-known/openapi.yaml');
$controller = new OpenApiController();
$response = $controller->get($request);
$this->assertEquals(200, $response->getStatusCode());
$this->assertStringContainsString('openapi: 3.0.0', (string)$response->getBody());
}
public function testBuildsJson(): void
{
$request = $this->getMockRequest(uri: '/.well-known/openapi.json');
$controller = new OpenApiController();
$response = $controller->get($request);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));
$this->assertNotFalse(json_decode($response->getBody()->getContents()));
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Events;
use Illuminate\Contracts\Container\BindingResolutionException;
use Siteworxpro\Tests\Unit;
class DispatcherTest extends Unit
{
/**
* @throws \Throwable
* @throws BindingResolutionException
*/
public function testRegistersListeners(): void
{
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$eventFired = false;
$dispatcher->listen('TestEvent', function ($event) use (&$eventFired) {
$this->assertEquals('TestEvent', $event);
$eventFired = true;
});
$dispatcher->dispatch('TestEvent');
$this->assertTrue($eventFired, 'The TestEvent listener was not fired.');
}
/**
* @throws BindingResolutionException
*/
public function testPushesEvents()
{
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$eventsFired = 0;
$dispatcher->listen('PushedEvent1', function ($event) use (&$eventsFired) {
$eventsFired++;
$this->assertEquals('PushedEvent1', $event);
});
$dispatcher->listen('PushedEvent2', function ($event) use (&$eventsFired) {
$eventsFired++;
$this->assertEquals('PushedEvent2', $event);
});
$dispatcher->push('PushedEvent1');
$dispatcher->push('PushedEvent2');
unset($dispatcher); // Trigger destructor
$this->assertEquals(2, $eventsFired);
}
/**
* @throws BindingResolutionException
* @throws \Throwable
*/
public function testFlushEvent(): void
{
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$eventFired = false;
$dispatcher->listen('FlushEvent', function ($event) use (&$eventFired) {
$this->assertEquals('FlushEvent', $event);
$eventFired = true;
});
$dispatcher->push('FlushEvent');
$dispatcher->flush('FlushEvent');
$this->assertTrue($eventFired, 'The FlushEvent listener was not fired.');
}
/**
* @throws BindingResolutionException
*/
public function testHasListeners(): void
{
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$this->assertFalse(
$dispatcher->hasListeners(
'NonExistentEvent'
),
'Expected no listeners for NonExistentEvent.'
);
$dispatcher->listen('ExistingEvent', function () {
// Listener logic
});
$this->assertTrue(
$dispatcher->hasListeners(
'ExistingEvent'
),
'Expected listeners for ExistingEvent.'
);
}
/**
* @throws BindingResolutionException
* @throws \Throwable
*/
public function testForgetEvent(): void
{
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$eventFired = false;
$dispatcher->listen('ForgetEvent', function ($event) use (&$eventFired) {
$this->assertEquals('ForgetEvent', $event);
$eventFired = true;
});
$dispatcher->push('ForgetEvent');
$dispatcher->forget('ForgetEvent');
unset($dispatcher); // Trigger destructor
$this->assertFalse($eventFired, 'The ForgetEvent listener was fired but should have been forgotten.');
}
/**
* @throws BindingResolutionException
*/
public function testForgetPushed()
{
$this->expectNotToPerformAssertions();
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$dispatcher->listen('EventToForget', function () {
$this->fail('The EventToForget listener was fired but should have been forgotten.');
});
$dispatcher->push('EventToForget');
$dispatcher->forgetPushed();
unset($dispatcher); // Trigger destructor
}
/**
* @throws BindingResolutionException
*/
public function testToArray(): void
{
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$dispatcher->listen('ArrayEvent', function () {
// Listener logic
});
$arrayRepresentation = $dispatcher->toArray();
$this->assertArrayHasKey('ArrayEvent', $arrayRepresentation);
}
/**
* @throws BindingResolutionException
* @throws \Throwable
*/
public function testSubscriber()
{
$subscriber = $this->getMockBuilder('Siteworxpro\App\Events\Subscribers\Subscriber')
->onlyMethods(['handle'])
->getMock();
$subscriber->expects($this->once())
->method('handle')
->with('SubscribedEvent', [])
->willReturn(null);
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$dispatcher->subscribe($subscriber);
$dispatcher->dispatch('SubscribedEvent');
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Events\Listeners;
use Illuminate\Database\Events\ConnectionEstablished;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LogLevel;
use Siteworxpro\App\Events\Listeners\Database\Connected;
use Siteworxpro\App\Log\Logger;
use Siteworxpro\Tests\Unit;
class ConnectedTest extends Unit
{
/**
* @throws ContainerExceptionInterface
* @throws \ReflectionException
* @throws NotFoundExceptionInterface
*/
protected function setUp(): void
{
parent::setUp();
$inputBuffer = fopen('php://memory', 'r+');
$logger = new Logger(LogLevel::DEBUG, $inputBuffer);
\Siteworxpro\App\Services\Facades\Logger::getFacadeContainer()->bind(Logger::class, fn() => $logger);
}
public function testHandlesEvent()
{
$this->expectNotToPerformAssertions();
$connectedEvent = $this->createMock(ConnectionEstablished::class);
$listener = new Connected();
$listener->__invoke($connectedEvent);
}
public function testThrowsException()
{
$this->expectException(\TypeError::class);
$listener = new Connected();
$listener->__invoke(new \stdClass());
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Facades;
use GuzzleHttp\Client;
use Siteworxpro\App\Services\Facades\Guzzle;
class GuzzleTest extends AbstractFacade
{
protected function getFacadeClass(): string
{
return Guzzle::class;
}
protected function getConcrete(): string
{
return Client::class;
}
}

View File

@@ -11,7 +11,7 @@ use Siteworxpro\App\Http\Middleware\CorsMiddleware;
use Siteworxpro\App\Services\Facades\Config; use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\Tests\Unit; use Siteworxpro\Tests\Unit;
class CorsMiddlewareTest extends Unit class CorsMiddlewareTest extends Middleware
{ {
public function testAllowsConfiguredOrigin(): void public function testAllowsConfiguredOrigin(): void
{ {
@@ -80,22 +80,4 @@ class CorsMiddlewareTest extends Unit
$this->assertEquals('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); $this->assertEquals('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
} }
private function mockHandler(Response $response): RequestHandlerInterface
{
return new class ($response) implements RequestHandlerInterface {
private Response $response;
public function __construct(Response $response)
{
$this->response = $response;
}
public function handle(
\Psr\Http\Message\ServerRequestInterface $request
): \Psr\Http\Message\ResponseInterface {
return $this->response;
}
};
}
} }

View File

@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Http\Middleware;
use DateTimeImmutable;
use Lcobucci\JWT\JwtFacade;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Token\Builder;
use League\Route\Dispatcher;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Http\Middleware\JwtMiddleware;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Guzzle;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum;
class JwtMiddlewareTest extends Middleware
{
private const string TEST_SIGNING_KEY = 'test_signing_key_123456444478901234';
private const string TEST_RSA_PRIVATE_KEY = <<<EOD
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAqTheAdlelxJL0K15BqUEo0lBzY06P7J0PhMfPlg2fgIJH+ng
ZmrpYFhBkj2L5Fnvxz0y58eu9WhhokwpS0GzgFIw+KfLV/WLX4PgionsQshrt0Pi
XvthaSH1xuYtg2N13dVVTv3Au0BBFLUHMrQ+bO5hgvowHBNfFf0GaHLW2m0eZ2Um
hWbtdv4HxrXBO5gI2N4UevyQ+inczN7RBZR6ZzyNoDO6Up6kS23/58zOruO+PGi7
q9eb7hU+getpVgA29wEWMgT+N6c5n5AcENgM1sHxZK43GR5vhMGbVJqnrUsMGof7
rT9Lxey3gjPS2r5nz2PNFcQ1i07QKDzvQHp2wwIDAQABAoIBAFMAC9QaWzP8TGWJ
gNBKhnDU0MrSl5yAmlWMKYn52JiLxQ/7Ng7mJ5wTDe5986zIlDyEfwCCyAUk8qaZ
drOsATBSoCSGoM1+6aKq26r4JYNILNVSHal64XegqZ2qbu6ADWMGbXZ2Ll9qD8Hp
XSN4lxn0/q0wrAJJWh094zO+CDZP+zBbX9oHxb5JAVxjCaNW84sI6/6agXM5zzgK
wcBt5Y0i8V8f7n9kg+CPNqY6BKg7o2ONFYTEVKuuEnVS/eupHQwBWExPCdxc85Tb
YqFL0dmgehE0OTQ6FrEN7Xh6jE4GMJtWmTvBNpqhsMZ0i08tAZSPs+Us9rnppKkK
T1SC2xECgYEA7yOv4C7dtHmFbn0YfnbBEfgvGAubv5jPDtZ5u6tUEhhU3rOcWexM
Xhj7OFV4I8lbu2t7GY+2BR7Y2ikOLW9MrOGo6qWhsjTQuZs6QaRKObcPvl2s0LYY
GxD1u84VjHPzID2pKVPqxaQ7KdcIaujAedWwAf4PV/uK2prKdGvzIksCgYEAtSau
4Ml1UpXvKxiBcVKsHIoEO0g3NL1+wAbdStg8TFi+leCMJoPwZ01t64BTtHF+pgDP
vn6VEgDSP3J4+W3dVhoajQeKBioT3MpDRP/qKDsImi2zJrg+hh9DMTlZd0Ab3EXv
ycjw3FWRcpcU/1l261fA/m3QPwZikF2VlO/0cmkCgYEAvtefCuy718RHHObOPlZt
O/bxNmJFOEEttOyql39iB1LNoDB8bTLruwh6q/lheEXAZDChO8P5gdqdOnUbMF0r
Nqib0i6+fOYzUHw1oJ8I8UhLUyOUv7ciQ69kPC15+u2psCglMKscp/+pi3lk6VS4
DkLfRKfI/PDsXgq72O8xSEMCgYEApukSnvngyQxvR1UYB7N19AHTLlA21bh4LjTk
905QGMR4Lp6sY9yTyIsWabRe69bbK9d5kvsNHX52OpGeF6z8EJaSujklGtLwZDJV
UyE9vn3OSkkrVdTTfz8U6Sj/XxpJ0Wb7LwCftVR+ZIgCh9kF8ohzwbqq8zdN39jq
t0V1BWkCgYEA2Mk2gOdYAN8aZgydFYKhogY5UNK/CFpq7hhekEyt73uxzxguVpZn
AJ9mq2L1CVJ5WqAUk2IzioeR7XAndntesbOafDuR4mhCUJhX+m/YQlKbTrs2dScR
S88z05AnmQmr5eCbQmVULZGo9xeLDB+GDWvvjpQ+NWcha2uO0O0RTQY=
-----END RSA PRIVATE KEY-----
EOD;
private const string TEST_JWKS_JSON = <<<EOD
{
"keys": [
{
"alg": "RS256",
"e": "AQAB",
"ext": true,
"key_ops": [
"verify"
],
"kty": "RSA",
"n": "qTheAdlelxJL0K15BqUEo0lBzY06P7J0PhMfPlg2fgIJH-ngZmrpYFhBkj2L5Fnvxz0y58eu9WhhokwpS0GzgFIw-KfLV_WLX4PgionsQshrt0PiXvthaSH1xuYtg2N13dVVTv3Au0BBFLUHMrQ-bO5hgvowHBNfFf0GaHLW2m0eZ2UmhWbtdv4HxrXBO5gI2N4UevyQ-inczN7RBZR6ZzyNoDO6Up6kS23_58zOruO-PGi7q9eb7hU-getpVgA29wEWMgT-N6c5n5AcENgM1sHxZK43GR5vhMGbVJqnrUsMGof7rT9Lxey3gjPS2r5nz2PNFcQ1i07QKDzvQHp2ww",
"kid": "2o5IaHnjxYtkpNWEcdPlwnaRJnaCJ2k2LY2nR4z6cN4=",
"use": "sig"
}
]
}
EOD;
public function getClass(): object
{
return new class {
public function getCallable(): array
{
return [$this, 'index'];
}
#[Jwt]
public function index()
{
// Dummy method for testing
}
};
}
protected function setUp(): void
{
parent::setUp();
Config::set('jwt.signing_key', self::TEST_SIGNING_KEY);
}
/**
* @throws \JsonException
*/
public function testIgnoresNoJwtAttribute()
{
$class = new class {
public function getCallable(): array
{
return [$this, 'index'];
}
public function index()
{
// Dummy method for testing
}
};
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$handler
->shouldReceive('handle')
->once()
->andReturn(new Response(200));
$request = new ServerRequest('GET', '/');
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::OK->value, $response->getStatusCode());
}
/**
* @throws \JsonException
*/
public function testIgnoresJwtAttributeButNoToken()
{
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
}
/**
* @throws \JsonException
*/
public function testInvalidToken()
{
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . 'invalid_token_string');
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
$this->assertStringContainsString(
'Unauthorized: Invalid token',
$response->getBody()->getContents()
);
}
/**
* @throws \JsonException
*/
public function testJwtAttributeWithTokenButWrongAud()
{
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt());
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
$this->assertStringContainsString(
'The token is not allowed to be used by this audience',
$response->getBody()->getContents()
);
}
/**
* @throws \JsonException
*/
public function testJwtAttributeWithTokenButWrongIss()
{
Config::set('jwt.audience', 'https://client-app.io');
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt());
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
$this->assertStringContainsString(
'The token was not issued by the given issuers',
$response->getBody()->getContents()
);
}
/**
* @throws \JsonException
*/
public function testJwtAttributeWithTokenWithDiffIssuer()
{
Config::set('jwt.audience', 'https://client-app.io');
Config::set('jwt.issuer', 'https://different-issuer.io');
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt());
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
$this->assertStringContainsString(
'The token was not issued by the given issuers',
$response->getBody()->getContents()
);
}
public function testJwtAttributeWithToken()
{
Config::set('jwt.audience', 'https://client-app.io');
Config::set('jwt.issuer', 'https://api.my-awesome-app.io');
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$handler
->shouldReceive('handle')
->once()
->andReturn(new Response(200));
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt());
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::OK->value, $response->getStatusCode());
}
/**
* @throws \JsonException
*/
public function testJwtFromJwkEndpoint()
{
Config::set('jwt.audience', 'https://client-app.io');
Config::set('jwt.issuer', 'https://api.my-awesome-app.io');
Redis::partialMock()->shouldReceive('get')->andReturn(null);
Redis::shouldReceive('set')->andReturn('OK');
Guzzle::partialMock()->shouldReceive('get')
->with('https://test.com/.well-known/openid-configuration')
->andReturn(new Response(200, [], json_encode([
'jwks_uri' => 'https://test.com/keys'
], JSON_THROW_ON_ERROR)));
Guzzle::shouldReceive('get')
->with('https://test.com/keys')
->andReturn(new Response(200, [], self::TEST_JWKS_JSON));
Config::set('jwt.signing_key', 'https://test.com/.well-known/openid-configuration');
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$handler
->shouldReceive('handle')
->once()
->andReturn(new Response(200));
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwtRsa());
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::OK->value, $response->getStatusCode());
}
/**
* @throws \JsonException
*/
public function testCatchesInvalidJwksUrl()
{
Config::set('jwt.signing_key', 'https://test.com/.well-known/openid-configuration');
Redis::partialMock()->shouldReceive('get')->andReturn(null);
Redis::shouldReceive('set')->andReturn('OK');
Guzzle::partialMock()->shouldReceive('get')
->with('https://test.com/.well-known/openid-configuration')
->andReturn(new Response(200, [], json_encode([], JSON_THROW_ON_ERROR)));
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwtRsa());
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::INTERNAL_SERVER_ERROR->value, $response->getStatusCode());
}
private function getJwtRsa(): string
{
$key = InMemory::plainText(self::TEST_RSA_PRIVATE_KEY);
$signer = new \Lcobucci\JWT\Signer\Rsa\Sha256();
$token = new JwtFacade()->issue(
$signer,
$key,
static fn(
Builder $builder,
DateTimeImmutable $issuedAt
): Builder => $builder
->issuedBy('https://api.my-awesome-app.io')
->permittedFor('https://client-app.io')
->expiresAt($issuedAt->modify('+10 minutes'))
);
return $token->toString();
}
private function getJwt(): string
{
$key = InMemory::plainText(self::TEST_SIGNING_KEY);
$signer = new Sha256();
$token = new JwtFacade()->issue(
$signer,
$key,
static fn(
Builder $builder,
DateTimeImmutable $issuedAt
): Builder => $builder
->issuedBy('https://api.my-awesome-app.io')
->permittedFor('https://client-app.io')
->expiresAt($issuedAt->modify('+10 minutes'))
);
return $token->toString();
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Http\Middleware;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\Tests\Unit;
abstract class Middleware extends Unit
{
protected function mockHandler(Response $response): RequestHandlerInterface
{
return new class ($response) implements RequestHandlerInterface {
private Response $response;
public function __construct(Response $response)
{
$this->response = $response;
}
public function handle(
ServerRequestInterface $request
): ResponseInterface {
return $this->response;
}
};
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Http\Middleware;
use League\Route\Dispatcher;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Attributes\Guards\Scope;
use Siteworxpro\App\Http\Middleware\ScopeMiddleware;
use Siteworxpro\HttpStatus\CodesEnum;
class ScopeMiddlewareTest extends Middleware
{
/**
* @throws \ReflectionException
* @throws \JsonException
*/
public function testHandlesNoScopes()
{
$class = new class {
public function getCallable(): array
{
return [ $this, 'index' ];
}
public function index()
{
// Dummy method for testing
}
};
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$handler
->shouldReceive('handle')
->once()
->andReturn(new Response(200));
$request = new ServerRequest('GET', '/');
$middleware = new ScopeMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @throws \ReflectionException
* @throws \JsonException
*/
public function testAllowsWithScope()
{
$class = new class {
public function getCallable(): array
{
return [ $this, 'index' ];
}
#[Scope(['admin'])]
public function index()
{
// Dummy method for testing
}
};
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$handler
->shouldReceive('handle')
->once()
->andReturn(new Response(200));
$request = new ServerRequest('GET', '/')->withAttribute('scope', ['admin', 'user']);
$middleware = new ScopeMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::OK->value, $response->getStatusCode());
}
/**
* @throws \ReflectionException
* @throws \JsonException
*/
public function testDisallowsWithScope()
{
$class = new class {
public function getCallable(): array
{
return [ $this, 'index' ];
}
#[Scope(['admin'])]
public function index()
{
// Dummy method for testing
}
};
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$middleware = new ScopeMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::FORBIDDEN->value, $response->getStatusCode());
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Siteworxpro\Tests\Http\Responses;
use Siteworxpro\App\Http\Responses\NotFoundResponse;
use Siteworxpro\Tests\Unit;
class NotFoundResponseTest extends Unit
{
public function testToArray(): void
{
$response = new NotFoundResponse('/api/resource', ['key' => 'value']);
$expected = [
'status_code' => 404,
'message' => 'The requested resource /api/resource was not found.',
'context' => ['key' => 'value'],
];
$this->assertEquals($expected, $response->toArray());
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Siteworxpro\Tests\Http\Responses;
use Siteworxpro\App\Http\Responses\ServerErrorResponse;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\Tests\Unit;
class ServerErrorResponseTest extends Unit
{
public function testToArrayInDevMode(): void
{
Config::set('app.dev_mode', true);
try {
// Simulate an exception to generate a server error response
throw new \Exception('A Test Error occurred.');
} catch (\Exception $e) {
$response = new ServerErrorResponse($e, ['operation' => 'data_processing']);
$expected = [
'status_code' => 500,
'message' => 'A Test Error occurred.',
'context' => [
'operation' => 'data_processing'
],
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTrace(),
];
$this->assertEquals($expected, $response->toArray());
}
}
public function testToArrayNotInDevMode(): void
{
try {
throw new \Exception('A Test Error occurred.');
} catch (\Exception $exception) {
$response = new ServerErrorResponse($exception);
$expected = [
'status_code' => 500,
'message' => 'An internal server error occurred.',
];
$this->assertEquals($expected, $response->toArray());
}
}
public function testToArrayIfCodeIsSet(): void
{
try {
throw new \Exception('A Test Error occurred.', 1234);
} catch (\Exception $exception) {
$response = new ServerErrorResponse($exception);
$expected = [
'status_code' => 1234,
'message' => 'An internal server error occurred.',
];
$this->assertEquals($expected, $response->toArray());
}
}
public function testToArrayIfCodeIsSetDevMode(): void
{
Config::set('app.dev_mode', true);
try {
throw new \Exception('A Test Error occurred.', 1234);
} catch (\Exception $exception) {
$response = new ServerErrorResponse($exception);
$expected = [
'status_code' => 1234,
'message' => 'A Test Error occurred.',
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTrace(),
'context' => [],
];
$this->assertEquals($expected, $response->toArray());
}
}
}

View File

@@ -34,7 +34,7 @@ class LoggerRpcTest extends Unit
$mock = Mockery::mock(LoggerInterface::class); $mock = Mockery::mock(LoggerInterface::class);
$mock->expects('debug') $mock->expects('debug')
->with('message', ['key' => 'value']) ->with('message', ['key' => 'value'])
->once(); ->times(1);
\Siteworxpro\App\Services\Facades\Logger::getFacadeContainer() \Siteworxpro\App\Services\Facades\Logger::getFacadeContainer()
->bind(\RoadRunner\Logger\Logger::class, function () use ($mock) { ->bind(\RoadRunner\Logger\Logger::class, function () use ($mock) {
@@ -46,8 +46,6 @@ class LoggerRpcTest extends Unit
$logger->debug('message', ['key' => 'value']); $logger->debug('message', ['key' => 'value']);
$mock->shouldHaveReceived('debug'); $mock->shouldHaveReceived('debug');
Mockery::close();
} }
/** /**
@@ -76,7 +74,6 @@ class LoggerRpcTest extends Unit
$logger->notice('message', ['key' => 'value']); $logger->notice('message', ['key' => 'value']);
$mock->shouldHaveReceived('info')->times(2); $mock->shouldHaveReceived('info')->times(2);
Mockery::close();
} }
/** /**
@@ -104,7 +101,6 @@ class LoggerRpcTest extends Unit
$logger->warning('message', ['key' => 'value']); $logger->warning('message', ['key' => 'value']);
$mock->shouldHaveReceived('warning'); $mock->shouldHaveReceived('warning');
Mockery::close();
} }
/** /**
@@ -135,7 +131,6 @@ class LoggerRpcTest extends Unit
$logger->emergency('message', ['key' => 'value']); $logger->emergency('message', ['key' => 'value']);
$mock->shouldHaveReceived('error')->times(4); $mock->shouldHaveReceived('error')->times(4);
Mockery::close();
} }
/** /**
@@ -162,6 +157,5 @@ class LoggerRpcTest extends Unit
$logger->log('notaloglevel', 'message', ['key' => 'value']); $logger->log('notaloglevel', 'message', ['key' => 'value']);
$mock->shouldHaveReceived('log')->times(1); $mock->shouldHaveReceived('log')->times(1);
Mockery::close();
} }
} }

View File

@@ -4,12 +4,18 @@ declare(strict_types=1);
namespace Siteworxpro\Tests\Log; namespace Siteworxpro\Tests\Log;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LogLevel; use Psr\Log\LogLevel;
use Siteworxpro\App\Log\Logger; use Siteworxpro\App\Log\Logger;
use Siteworxpro\Tests\Unit; use Siteworxpro\Tests\Unit;
class LoggerTest extends Unit class LoggerTest extends Unit
{ {
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
private function getLoggerWithBuffer(string $logLevel): array private function getLoggerWithBuffer(string $logLevel): array
{ {
$inputBuffer = fopen('php://memory', 'r+'); $inputBuffer = fopen('php://memory', 'r+');
@@ -21,6 +27,10 @@ class LoggerTest extends Unit
return stream_get_contents($inputBuffer, -1, 0); return stream_get_contents($inputBuffer, -1, 0);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
private function testLogLevel(string $level): void private function testLogLevel(string $level): void
{ {
[$logger, $inputBuffer] = $this->getLoggerWithBuffer($level); [$logger, $inputBuffer] = $this->getLoggerWithBuffer($level);
@@ -33,6 +43,10 @@ class LoggerTest extends Unit
$this->assertEquals('value', $decoded['context']['key']); $this->assertEquals('value', $decoded['context']['key']);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
private function testLogLevelEmpty(string $configLevel, string $logLevel): void private function testLogLevelEmpty(string $configLevel, string $logLevel): void
{ {
[$logger, $inputBuffer] = $this->getLoggerWithBuffer($configLevel); [$logger, $inputBuffer] = $this->getLoggerWithBuffer($configLevel);
@@ -42,57 +56,101 @@ class LoggerTest extends Unit
$this->assertEmpty($output); $this->assertEmpty($output);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsDebugMessageWhenLevelIsDebug(): void public function testLogsDebugMessageWhenLevelIsDebug(): void
{ {
$this->testLogLevel(LogLevel::DEBUG); $this->testLogLevel(LogLevel::DEBUG);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsInfoMessageWhenLevelIsInfo(): void public function testLogsInfoMessageWhenLevelIsInfo(): void
{ {
$this->testLogLevel(LogLevel::INFO); $this->testLogLevel(LogLevel::INFO);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsWarningMessageWhenLevelIsWarning(): void public function testLogsWarningMessageWhenLevelIsWarning(): void
{ {
$this->testLogLevel(LogLevel::WARNING); $this->testLogLevel(LogLevel::WARNING);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsErrorMessageWhenLevelIsError(): void public function testLogsErrorMessageWhenLevelIsError(): void
{ {
$this->testLogLevel(LogLevel::ERROR); $this->testLogLevel(LogLevel::ERROR);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsCriticalMessageWhenLevelIsCritical(): void public function testLogsCriticalMessageWhenLevelIsCritical(): void
{ {
$this->testLogLevel(LogLevel::CRITICAL); $this->testLogLevel(LogLevel::CRITICAL);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsAlertMessageWhenLevelIsAlert(): void public function testLogsAlertMessageWhenLevelIsAlert(): void
{ {
$this->testLogLevel(LogLevel::ALERT); $this->testLogLevel(LogLevel::ALERT);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsEmergencyMessageWhenLevelIsEmergency(): void public function testLogsEmergencyMessageWhenLevelIsEmergency(): void
{ {
$this->testLogLevel(LogLevel::EMERGENCY); $this->testLogLevel(LogLevel::EMERGENCY);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsNoticeMessageWhenLevelIsNotice(): void public function testLogsNoticeMessageWhenLevelIsNotice(): void
{ {
$this->testLogLevel(LogLevel::NOTICE); $this->testLogLevel(LogLevel::NOTICE);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testDoesNotLogWhenMinimumLevelIsInfo(): void public function testDoesNotLogWhenMinimumLevelIsInfo(): void
{ {
$this->testLogLevelEmpty(LogLevel::INFO, LogLevel::DEBUG); $this->testLogLevelEmpty(LogLevel::INFO, LogLevel::DEBUG);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testDoesNotLogWhenMinimumLevelIsWarning(): void public function testDoesNotLogWhenMinimumLevelIsWarning(): void
{ {
$this->testLogLevelEmpty(LogLevel::WARNING, LogLevel::INFO); $this->testLogLevelEmpty(LogLevel::WARNING, LogLevel::INFO);
$this->testLogLevelEmpty(LogLevel::WARNING, LogLevel::DEBUG); $this->testLogLevelEmpty(LogLevel::WARNING, LogLevel::DEBUG);
} }
/**
* @throws NotFoundExceptionInterface
* @throws ContainerExceptionInterface
*/
public function testDoesNotLogWhenMinimumLevelIsError(): void public function testDoesNotLogWhenMinimumLevelIsError(): void
{ {
$this->testLogLevelEmpty(LogLevel::ERROR, LogLevel::DEBUG); $this->testLogLevelEmpty(LogLevel::ERROR, LogLevel::DEBUG);
@@ -100,12 +158,20 @@ class LoggerTest extends Unit
$this->testLogLevelEmpty(LogLevel::ERROR, LogLevel::WARNING); $this->testLogLevelEmpty(LogLevel::ERROR, LogLevel::WARNING);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testDoesNotLogWhenMinimumLevelIsNotice(): void public function testDoesNotLogWhenMinimumLevelIsNotice(): void
{ {
$this->testLogLevelEmpty(LogLevel::NOTICE, LogLevel::DEBUG); $this->testLogLevelEmpty(LogLevel::NOTICE, LogLevel::DEBUG);
$this->testLogLevelEmpty(LogLevel::NOTICE, LogLevel::INFO); $this->testLogLevelEmpty(LogLevel::NOTICE, LogLevel::INFO);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsMessageWithEmptyContext(): void public function testLogsMessageWithEmptyContext(): void
{ {
[$logger, $buffer] = $this->getLoggerWithBuffer(LogLevel::INFO); [$logger, $buffer] = $this->getLoggerWithBuffer(LogLevel::INFO);
@@ -118,6 +184,10 @@ class LoggerTest extends Unit
$this->assertEquals('Message without context', $decoded['message']); $this->assertEquals('Message without context', $decoded['message']);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsMessageWithComplexContext(): void public function testLogsMessageWithComplexContext(): void
{ {
[$logger, $buffer] = $this->getLoggerWithBuffer(LogLevel::INFO); [$logger, $buffer] = $this->getLoggerWithBuffer(LogLevel::INFO);
@@ -135,6 +205,10 @@ class LoggerTest extends Unit
$this->assertEquals('value', $decoded['context']['nested']['key']); $this->assertEquals('value', $decoded['context']['nested']['key']);
} }
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsStringableMessage(): void public function testLogsStringableMessage(): void
{ {
[$logger, $buffer] = $this->getLoggerWithBuffer(LogLevel::INFO); [$logger, $buffer] = $this->getLoggerWithBuffer(LogLevel::INFO);

30
tests/Models/UserTest.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Models;
use Siteworxpro\App\Models\User;
use Siteworxpro\Tests\Unit;
class UserTest extends Unit
{
public function testFormatsName(): void
{
$user = new User();
$user->first_name = 'John';
$user->last_name = 'Doe';
$this->assertEquals('John Doe', $user->full_name);
}
public function testFormatsEmail(): void
{
$user = new User();
$user->first_name = 'Jane';
$user->last_name = 'Smith';
$user->email = 'jane.smith@email.com';
$this->assertEquals('Jane Smith <jane.smith@email.com>', $user->formatted_email);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\ServiceProviders;
use Siteworxpro\App\Services\ServiceProviders\DispatcherServiceProvider;
class DispatcherServiceProviderTest extends AbstractServiceProvider
{
protected function getProviderClass(): string
{
return DispatcherServiceProvider::class;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Siteworxpro\Tests; namespace Siteworxpro\Tests;
use Illuminate\Container\Container; use Illuminate\Container\Container;
use Mockery;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Siteworx\Config\Config as SWConfig; use Siteworx\Config\Config as SWConfig;
use Siteworxpro\App\Services\Facade; use Siteworxpro\App\Services\Facade;
@@ -12,13 +13,25 @@ use Siteworxpro\App\Services\Facades\Config;
abstract class Unit extends TestCase abstract class Unit extends TestCase
{ {
protected function getContainer(): Container
{
$container = Facade::getFacadeContainer();
if ($container === null) {
$container = new Container();
Facade::setFacadeContainer($container);
return $container;
}
return $container;
}
/** /**
* @throws \ReflectionException * @throws \ReflectionException
*/ */
protected function setUp(): void protected function setUp(): void
{ {
$container = new Container(); $container = $this->getContainer();
Facade::setFacadeContainer($container);
$container->bind(SWConfig::class, function () { $container->bind(SWConfig::class, function () {
return SWConfig::load(__DIR__ . '/../config.php'); return SWConfig::load(__DIR__ . '/../config.php');
@@ -29,5 +42,6 @@ abstract class Unit extends TestCase
{ {
Config::clearResolvedInstances(); Config::clearResolvedInstances();
Facade::setFacadeContainer(null); Facade::setFacadeContainer(null);
Mockery::close();
} }
} }