25 Commits

Author SHA1 Message Date
e971d32f9d chore: update PHP CLI image to version 8.5.1 in Dockerfile
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 58s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m1s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m14s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 57s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m16s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 43s
2025-12-27 20:07:06 -05:00
2a060fb972 Docker build make target
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 54s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m1s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m15s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m9s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m13s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 48s
2025-12-27 19:56:02 -05:00
b53a95ebcf feat: register CommandBusProvider and simplify Logger facade usage in tests
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m50s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m40s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m55s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m9s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m56s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m22s
2025-12-27 19:23:24 -05:00
de0c95db2a feat: enhance service providers with provides method and integrate command bus in handlers
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m40s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m42s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m53s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m55s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m42s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m24s
2025-12-22 13:18:13 -05:00
cae1de6ef3 feat: implement command bus with attribute-based handler resolution and add example command and handler (#27)
All checks were successful
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m13s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m24s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m58s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m20s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m5s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m0s
Reviewed-on: #27
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-12-21 21:04:52 +00:00
84c3b392ba refactor: remove status_code from response classes and update related tests
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 3m5s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Failing after 2m56s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m12s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m32s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m17s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m42s
2025-12-10 08:45:51 -05:00
f59dcb2dcc feat: update Docker configuration for SSL support and improve service registration (#26)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m34s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m44s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m49s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m41s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m47s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m30s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 19m32s
🏗️✨ Build Workflow / 🖥️ 🔨 Build Migrations (push) Successful in 1m39s
Reviewed-on: #26
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-12-04 16:49:58 +00:00
8252ae4e53 fix: optimize Dockerfile by removing unnecessary build dependencies and improving healthcheck
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m43s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m37s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m48s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m42s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m43s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m19s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 19m58s
🏗️✨ Build Workflow / 🖥️ 🔨 Build Migrations (push) Successful in 1m53s
2025-12-04 10:05:51 -05:00
68ab2dcdd7 feat/grpc (#25)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m30s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m43s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m49s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m39s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m48s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m21s
Reviewed-on: #25
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-12-04 13:55:28 +00: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
96 changed files with 4346 additions and 446 deletions

View File

@@ -5,6 +5,12 @@ volumes:
services:
traefik:
labels:
- "traefik.enable=true"
- "traefik.http.routers.traefik.entrypoints=web-secure"
- "traefik.http.routers.traefik.rule=Host(`127.0.0.1`) && (PathPrefix(`/dashboard`) || PathPrefix(`/api`))"
- "traefik.http.routers.traefik.tls=true"
- "traefik.http.routers.traefik.service=api@internal"
image: traefik:latest
container_name: traefik
healthcheck:
@@ -15,15 +21,20 @@ services:
ports:
- "80:80"
- "443:443"
- "9001:9001"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
- "./ssl:/etc/ssl"
restart: always
command:
- "--providers.docker=true"
- "--api.insecure=true"
- "--ping"
- "--providers.file.filename=/etc/ssl/traefik.yml"
- "--providers.docker.exposedByDefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web-secure.address=:443"
- "--entrypoints.grpc.address=:9001"
- "--accesslog=true"
- "--entrypoints.web.http.redirections.entryPoint.to=web-secure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
@@ -31,16 +42,30 @@ services:
composer-runtime:
volumes:
- .:/app
- ..:/app
image: siteworxpro/composer
entrypoint: "/bin/sh -c 'while true; do sleep 30; done;'"
environment:
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:
volumes:
- ./db/migrations:/app/db/migrations
- ./bin:/app/bin
- ../db/migrations:/app/db/migrations
- ../bin:/app/bin
image: siteworxpro/migrate:v4.18.3
working_dir: /app
# entrypoint: "/bin/sh -c 'while true; do sleep 30; done;'"
@@ -65,12 +90,21 @@ services:
- "traefik.http.services.api.loadbalancer.healthcheck.path=/healthz"
- "traefik.http.services.api.loadbalancer.healthcheck.interval=5s"
- "traefik.http.services.api.loadbalancer.healthcheck.timeout=60s"
- "traefik.tcp.services.api.loadbalancer.server.port=9001"
- "traefik.http.services.api.loadbalancer.server.port=9501"
- "traefik.tcp.routers.grpc.entrypoints=grpc"
- "traefik.tcp.routers.grpc.rule=HostSNI(`localhost`) || HostSNI(`127.0.0.1`)"
- "traefik.tcp.routers.grpc.tls=true"
- "traefik.tcp.routers.grpc.service=api"
container_name: dev-runtime
volumes:
- .:/app
- ..:/app
build:
args:
KAFKA_ENABLED: "1"
context: .
KAFKA_ENABLED: "0"
UID: 0
USER: root
context: ..
dockerfile: Dockerfile
entrypoint: "/bin/sh -c 'while true; do sleep 30; done;'"
depends_on:
@@ -83,13 +117,17 @@ services:
postgres:
condition: service_healthy
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
PHP_IDE_CONFIG: serverName=localhost
WORKERS: 1
GRPC_WORKERS: 1
DEBUG: 1
REDIS_HOST: redis
DB_HOST: postgres
JWT_SIGNING_KEY: a-string-secret-at-least-256-bits-long
DEV_MODE: 1
## Kafka and Zookeeper for local development
kafka-ui:

83
.dev/ssl/localhost.crt Normal file
View File

@@ -0,0 +1,83 @@
-----BEGIN CERTIFICATE-----
MIIEFzCCA52gAwIBAgIURfvF11Q9R3Ue38Tr0BzIoUe0TKQwCgYIKoZIzj0EAwMw
MDEuMCwGA1UEAxMlU2l0ZXdvcnggSW50ZXJtZWRpYXRlIEVDMzg0IEF1dGhvcml0
eTAeFw0yNTEyMDQxNjM1NTFaFw0yNjEyMDQxNjM2MjFaMHExCzAJBgNVBAYTAlVT
MREwDwYDVQQIEwhWaXJnaW5pYTEVMBMGA1UEBxMMUHVyY2VsbHZpbGxlMSQwIgYD
VQQKExtTaXRld29yeCBQcm9mZXNzaW9uYWxzLCBMTEMxEjAQBgNVBAMTCWxvY2Fs
aG9zdDB2MBAGByqGSM49AgEGBSuBBAAiA2IABM+jXangYCOi01IMblAXJ6iFZE4v
SBBOZKNQCwGz8kKi5jyXtVwz6U26DMlBSK+InhhOFQlCRcP9ow8LtlQdaY2XnGKr
3X3zxdUZJVhLi/wog+I4igU3+xuyn1E/BgEZx6OCAjUwggIxMA4GA1UdDwEB/wQE
AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdDgQW
BBRAtanjiWMYAdpCCz0rEkyqf691bzAfBgNVHSMEGDAWgBQYBC15lPoGGGxbmwqY
MWL7jjI6azBJBggrBgEFBQcBAQQ9MDswOQYIKwYBBQUHMAGGLWh0dHBzOi8vdmF1
bHQuc2l0ZXdvcnhwcm8uY29tL3YxL3N3eF9pbnQvb2NzcDAaBgNVHREEEzARggls
b2NhbGhvc3SHBH8AAAEwHAYDVR0gBBUwEzAIBgZngQwBAgIwBwYFZ4EMAQEwggE1
BgNVHR8EggEsMIIBKDBioGCgXoZcaHR0cHM6Ly92YXVsdC5zaXRld29yeHByby5j
b20vdjEvc3d4X2ludC9pc3N1ZXIvMjVmMWRiNTAtZDQxOS1kZWQ3LTZiZjktZWNh
Y2E4NGEwMmY0L2NybC9wZW0wXqBcoFqGWGh0dHBzOi8vdmF1bHQuc2l0ZXdvcnhw
cm8uY29tL3YxL3N3eF9pbnQvaXNzdWVyLzI1ZjFkYjUwLWQ0MTktZGVkNy02YmY5
LWVjYWNhODRhMDJmNC9jcmwwYqBgoF6GXGh0dHBzOi8vdmF1bHQuc2l0ZXdvcnhw
cm8uY29tL3YxL3N3eF9pbnQvaXNzdWVyLzI1ZjFkYjUwLWQ0MTktZGVkNy02YmY5
LWVjYWNhODRhMDJmNC9jcmwvZGVyMAoGCCqGSM49BAMDA2gAMGUCMGxgZmKITQFu
H6j3j/t9MOTxhVsfOuoD0q3pMlp9d1u4Lg0THKUOzN06BVuXwC1eagIxAL2I/2a1
MMJmhky2EavzOsYt37Ae+1KGyELiwcWe5f/lActlw97pqRajpmqEmdo7PA==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEETCCAfmgAwIBAgIUIRpRFzFBITweYJETytgbPBgwbWgwDQYJKoZIhvcNAQEL
BQAwgZ0xCzAJBgNVBAYTAlVTMREwDwYDVQQIDAhWaXJnaW5pYTEVMBMGA1UEBwwM
UHVyY2VsbHZpbGxlMSQwIgYDVQQKDBtTaXRld29yeCBQcm9mZXNzaW9uYWxzLCBM
TEMxFDASBgNVBAMMC1NXWCBSb290IENBMSgwJgYJKoZIhvcNAQkBFhl3ZWJtYXN0
ZXJAc2l0ZXdvcnhwcm8uY29tMB4XDTIzMDMyMTE2MzAxNVoXDTMzMDMxODE2MzA0
NVowMDEuMCwGA1UEAxMlU2l0ZXdvcnggSW50ZXJtZWRpYXRlIEVDMzg0IEF1dGhv
cml0eTB2MBAGByqGSM49AgEGBSuBBAAiA2IABIhlP1W1O1WjoDFGFi5XbE0zVy90
76pQQ8VmSYtaZI9Jz5pAZTOQ073t/QkTWge8uhDaJ2J2uBhjQJGr5BPttvBcLJFI
52X7hJuck4oL0aukXiHYA5gZbC5LhKVvCyZcWqNjMGEwDgYDVR0PAQH/BAQDAgEG
MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFBgELXmU+gYYbFubCpgxYvuOMjpr
MB8GA1UdIwQYMBaAFHWCysIFrdsWYZJSjBO1pSQPETkTMA0GCSqGSIb3DQEBCwUA
A4ICAQBbw5roegt0tUc+gu0IcHDt56cUoqChmIZXzla8gTgg820ww/+Wm+vNAl8W
r3Y67LzK19CygoujD2o7M25syaByRiw9JdIfNGvBzklOOM+sus9DDmwSUBMCuljS
KLBhWzIrXDZwemzklGEbj+RL4o2ZiL01nx8xygDF55eaudNS0VzRzd2Hv0C+rm2i
nnwRNoKsL14YXc41rFBWwb5ViRuD2Wp0c9CivEOd4UNKgOnGyNxcNhjzNlY05t3c
NEeskEXiz21sj0vnrwM7olKyXPXDFUCCKGb21Sn9sWKldicumU1i1HdDGA1w50uh
NS4G4wqGQ8iZCq3h6JkpBMGPJPG3Dq6yuzrh8fmh56IqtKY4MxdKHb91MtFHnkw5
jCrxqpTKShRyqcBSx8QmXRXpec5FEB88NQ3aKhtFlNqXYphNRAI9bLIyGkdxUF/r
PCkZkKBhbsRvXT8Ii/K1PQHzliQqJxXhrrJEsIg2jiSQItBg52ZySzuw+Y6++h11
73XMKJ53oOeLcxvp2qJRwMkNTwVfNxDmKC0tIRdI+KoJYbYeN0Ev/pEdPdYl+hjY
uQhKMt1KtpUyYwPzTGPKGMnklKj/T3Qu7fmpsWxtAOuK7yLLMayBwXBlVBD23md+
UAfPR3FfVX+aRqqsvT7WI+SnlycJuYXs41ZPxBjLq2aB7fhAwQ==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGIjCCBAqgAwIBAgIJAKU+Idu5bncNMA0GCSqGSIb3DQEBCwUAMIGdMQswCQYD
VQQGEwJVUzERMA8GA1UECAwIVmlyZ2luaWExFTATBgNVBAcMDFB1cmNlbGx2aWxs
ZTEkMCIGA1UECgwbU2l0ZXdvcnggUHJvZmVzc2lvbmFscywgTExDMRQwEgYDVQQD
DAtTV1ggUm9vdCBDQTEoMCYGCSqGSIb3DQEJARYZd2VibWFzdGVyQHNpdGV3b3J4
cHJvLmNvbTAeFw0yMDA5MDgxMjU3NTJaFw00MDA5MDMxMjU3NTJaMIGdMQswCQYD
VQQGEwJVUzERMA8GA1UECAwIVmlyZ2luaWExFTATBgNVBAcMDFB1cmNlbGx2aWxs
ZTEkMCIGA1UECgwbU2l0ZXdvcnggUHJvZmVzc2lvbmFscywgTExDMRQwEgYDVQQD
DAtTV1ggUm9vdCBDQTEoMCYGCSqGSIb3DQEJARYZd2VibWFzdGVyQHNpdGV3b3J4
cHJvLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAN9JNyWot7VX
ODvru8S5/o6gdFuynA1l5T0uXSzWhROMYHndmY+n7pwQCwf1R8iLL3aat9sDRxqM
RRScD3nNW6UzC5xNz12wiuemf2KT82cTjmUBU3CvtjstbgkrQ/SrpR/Arvu2YwUe
tmL9ft/xaoGvZXx8LKpyRMrHA1FlS2st+RFWBC0yXTU/nL4/7YQKVEcbc3YZvgCT
P4/8pxH9u8W7kgnufQHHKEIZR9lxIUhQ7yvc61B3zMntbJsZV1N+0c7j5DXY5cfT
6zXlfG2hSX1dbhM56y8O8KiCFaWaDRZ9mwkfZGM0W58gkhXUPXOrIOwewLmvl2Z4
Vu43UkLfKhtQApxk6zodHRq1e2rNWSpBCGznT9XyoeO/spJ7yggNkleTa+SnnlmV
rHJS/YUp3/jAvJY2bCHQKFu/mguMY3Ub2X6eEBsVZOmUqDMbya1TPP6GCVqh4gUu
yip6qS9UksaTF6IN3IcrGhwtTyvp8BFqwVA0tMhgraf1rv6ZoXjY/NDuGjE1xXJg
Hg+gg2pIIRcXjcsG1tXFXTgxDqoh127ADg/gtq9cIyarMx4LdNTjnR+CnhjqvRkT
uiUBB1bwDc9pbX0ulfnR+VuIZtQ6PSuWwChnMdNBKmCgQT1J1AHWpQqnFTjg42NV
5QAdFOQxAnsq2DxkurVFEz2J3euZx1ZdAgMBAAGjYzBhMB0GA1UdDgQWBBR1gsrC
Ba3bFmGSUowTtaUkDxE5EzAfBgNVHSMEGDAWgBR1gsrCBa3bFmGSUowTtaUkDxE5
EzAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
AAOCAgEAZDjIlaAoMfRGdb3/i40s6nN18iWN2Chttd8dLXgV+/SZ8GrAU89JNJrK
ODaLZT1wHeWVz0LP3miByuvfrnH4qzPEOI2L6zEy/FJr8SCivjm7aUExyb5kTSXp
LkwVcOI9UfQb6lCy9Gs/rUEcWQjs5KS3dy6ZwBMaywq6sRj7MeXmhqXhj7aAyWFA
psnQsuP2XweWa9OX6Z+u78sebfoiJlOEUvV9VRNHQYpLUd75p6sti1Dm9blWkZEO
hyssi3kOJMH+g5pc9xNbD9gS+/pFUWxEVAhHOc0xdEIcHfV5oiiOUDD5EOIPi3xv
/NYTV7o7pv2/QlH09vO2PHdsy07lhsg7NoM3U+zYq609Ox78/b4PNd+TkdtYKebO
VumZ0xXab0lWbTVuno52k473ODQRA/v9YWHtuovW0Lzf5fDcBhVXTDeW21SmMJIx
B+dgJDh7ql7ruZqjMj+kePjM9Mm+M5pDZ6vrEtgiR2yQj/IE+LoQh/bxFHpFkIK8
I6AWoxABAvLZB+KHl1ufR5yOauJG2+SQRuzHNZvkAcdjmwpgfxcsB2mY7o0RbGmZ
VWm97P4P9iJhje/W4C0cGwVY5wRAMAg6SI1BpcW7YghB14UrKaxpEzHCdZIeeT94
GYzN2XNSSGW3s1anFedd5PQyRM7PlJIcloLYrqyWW6M7OwWnMXA=
-----END CERTIFICATE-----

6
.dev/ssl/localhost.key Normal file
View File

@@ -0,0 +1,6 @@
-----BEGIN EC PRIVATE KEY-----
MIGkAgEBBDBrpJYaCMqgu490fpZoIphGVspE33v3JwyD9B55HwSX/jykySs9NTOv
68YndzE9LNCgBwYFK4EEACKhZANiAATPo12p4GAjotNSDG5QFyeohWROL0gQTmSj
UAsBs/JCouY8l7VcM+lNugzJQUiviJ4YThUJQkXD/aMPC7ZUHWmNl5xiq91988XV
GSVYS4v8KIPiOIoFN/sbsp9RPwYBGcc=
-----END EC PRIVATE KEY-----

14
.dev/ssl/traefik.yml Normal file
View File

@@ -0,0 +1,14 @@
tls:
stores:
default:
defaultCertificate:
certFile: /etc/ssl/localhost.crt
keyFile: /etc/ssl/localhost.key
options:
default:
minVersion: VersionTLS13
preferServerCipherSuites: true
mintls13:
minVersion: VersionTLS13

View File

@@ -26,6 +26,12 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
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
uses: docker/setup-buildx-action@v3

View File

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

View File

@@ -6,6 +6,17 @@ server:
rpc:
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}
debug: ${DEBUG:-false}
reflection: ${GRPC_REFLECTION:-true}
destroy_timeout: 5s
proto:
- "protos/example.proto"
http:
pool:
allocate_timeout: 5s

View File

@@ -1,8 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name=" Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker">
<configuration default="false" name=".dev: Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker">
<deployment type="docker-compose.yml">
<settings>
<option name="sourceFilePath" value="docker-compose.yml" />
<option name="sourceFilePath" value=".dev/docker-compose.yml" />
</settings>
</deployment>
<method v="2" />

View File

@@ -5,7 +5,7 @@
<option name="interpreterName" value="composer-runtime" />
</PhpTestInterpreterSettings>
</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" />
</configuration>
</component>

View File

@@ -1,5 +1,5 @@
# Use the RoadRunner image as a base for the first stage
FROM ghcr.io/roadrunner-server/roadrunner:2025.1.4 AS roadrunner
FROM ghcr.io/roadrunner-server/roadrunner:2025.1.6 AS roadrunner
# Use the official Composer image as the base for the library stage
FROM siteworxpro/composer AS library
@@ -12,9 +12,11 @@ RUN composer install --optimize-autoloader --ignore-platform-reqs --no-dev
# 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.1-cli-alpine AS php
ARG KAFKA_ENABLED=0
ARG USER=appuser
ARG UID=1000
# Move the production PHP configuration file to the default location
RUN mv /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini \
@@ -26,6 +28,7 @@ RUN if [ "$KAFKA_ENABLED" -eq 1 ] ; then \
echo "Kafka support enabled" ; \
apk add autoconf g++ librdkafka-dev make --no-cache ; \
pecl install rdkafka && docker-php-ext-enable rdkafka ; \
apk del autoconf g++ make ; \
else \
echo "Kafka support disabled" ; \
exit 0 ; \
@@ -42,11 +45,19 @@ COPY --from=library /app/vendor /app/vendor
# Copy the RoadRunner configuration file and source
ADD src src/
ADD server.php .
ADD .rr.yaml .
ADD config.php .
ADD generated generated/
ADD protos protos/
ADD server.php cli.php grpc-worker.php .rr.yaml config.php ./
EXPOSE 9501
EXPOSE 9501 9001
# Create a non-root user and set ownership of the /app directory
RUN if [ ! $UID -eq 0 ] ; then addgroup -g $UID $USER && adduser -D -u $UID -G $USER $USER && chown -R $USER:$USER /app ; fi
USER $USER
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD curl -f http://localhost:9501/healthz || exit 1
# Entrypoint command to run the RoadRunner server with the specified configuration
ENTRYPOINT ["rr", "serve", "-c", ".rr.yaml", "-s"]
ENTRYPOINT ["rr", "serve"]
CMD ["-c", ".rr.yaml", "-s"]

139
README.md
View File

@@ -2,55 +2,132 @@
![pipeline status](https://gitea.siteworxpro.com/siteworxpro/Php-Template/actions/workflows/tests.yml/badge.svg?branch=master&style=flat-square)
## Overview
This is a PHP project template that provides a structured development environment using Docker Compose and Make.
It includes tools for code quality, testing, dependency management, and gRPC support.
## Dev Environment
### Prerequisites
- Docker
- Docker Compose
This project uses Docker Compose and Make to manage the development environment. The `makefile` provides convenient
commands for common development tasks.
### migrations
## Prerequisites
create a new migration
```shell
docker run --rm -v $(PWD):/app siteworxpro/migrate:v4.18.3 create -ext sql -dir /app/db/migrations -seq create_users_table
- Docker and Docker Compose
- Make
- protoc (Protocol Buffers compiler) - for gRPC code generation
## Quick Start
```bash
# Install PHP dependencies
make composer-install
# Start the development container
make start
# Run the application server
make run
```
```text
postgres://siteworxpro:password@localhost:5432/siteworxpro?sslmode=disable
## Available Commands
### Container Management
- `make start` - Start the development runtime container
- `make stop` - Stop and remove all containers
- `make restart` - Restart the development container
- `make rebuild` - Rebuild containers (use after Dockerfile changes)
- `make sh` - Open a shell in the development container
- `make ps` - Show running containers
### Application
- `make run` - Run the application server (RoadRunner)
- `make migrate` - Run database migrations
### Composer & Dependencies
- `make composer-install` - Install PHP dependencies
- `make composer-update` - Update PHP dependencies
- `make composer-require package=vendor/package` - Add a new dependency
- `make composer-require-dev package=vendor/package` - Add a new dev dependency
### Code Quality
- `make lint` - Run linters (phpcs and phpstan)
- `make fmt` - Format code with php-cs-fixer
- `make test` - Run the test suite (phpunit)
- `make license-check` - Check license headers in source files
### Debugging & Coverage
- `make enable-debug` - Enable Xdebug for debugging
- `make enable-coverage` - Enable PCOV for code coverage
### gRPC
- `make protoc` - Generate PHP gRPC code from `.proto` files
### CI Workflow
- `make ci` - Run the full CI pipeline locally (install, license check, lint, test)
### Help
- `make help` - Show all available commands with descriptions
## Common Workflows
### Starting Development
```bash
make composer-install
make start
make run
```
```shell
docker run --rm -v $(PWD):/app siteworxpro/migrate:v4.18.3 -database "postgres://siteworxpro:password@localhost:5432/siteworxpro?sslmode=disable" -path /app/db/migrations up
### Adding a New Package
```bash
make composer-require package=vendor/package-name
```
### Starting the Runtime
```shell
docker-compose up -d
```
### Start the server
```shell
docker exec -it template-dev-runtime-1 rr serve
### Running Tests
```bash
make test
```
You can access the api at `http://localhost:9501/`
### Code Quality Check
### Xdebug
xdebug needs to be built into the container before it will work
```shell
docker exec -it php-template-composer-runtime-1 bin/xdebug.sh
```bash
make lint
make fmt
```
### Install the dependencies
```shell
docker run --rm -v $(PWD):/app siteworxpro/composer install --ignore-platform-reqs
### Debugging
```bash
make enable-debug
make run
```
### Running all tests
```shell
docker run --rm -v $(PWD):/app siteworxpro/composer run tests:all
```
## Notes
- All commands run inside Docker containers, ensuring a consistent environment
- The development runtime uses RoadRunner as the application server
- Composer commands run in a separate `composer-runtime` container
- Database migrations run in a dedicated `migration-container`
## Additional Information
### Accessing Services
- You can access the api at [https://localhost](https://localhost)
- Traefik dashboard is at [https://127.0.0.1/dashboard/](https://127.0.0.1/dashboard/)
- the grpc server is at [tcp://localhost:9001](tcp://localhost:9001)
## License

View File

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

View File

@@ -4,11 +4,12 @@
"autoload": {
"psr-4": {
"Siteworxpro\\App\\": "src/",
"Siteworxpro\\Tests\\": "tests/"
"Siteworxpro\\Tests\\": "tests/",
"GRPC\\": "generated/GRPC"
}
},
"require": {
"php": "^8.4",
"php": "^8.5",
"league/route": "^6.2.0",
"illuminate/database": "^v12.34.0",
"spiral/roadrunner-http": "^v3.6.0",
@@ -21,12 +22,18 @@
"lcobucci/jwt": "^5.6",
"adhocore/cli": "^1.9",
"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",
"league/tactician": "^1.1"
},
"require-dev": {
"phpunit/phpunit": "^12.4",
"mockery/mockery": "^1.6",
"squizlabs/php_codesniffer": "^3.12",
"squizlabs/php_codesniffer": "^4.0",
"lendable/composer-license-checker": "^1.2",
"phpstan/phpstan": "^2.1.31",
"kwn/php-rdkafka-stubs": "^2.2"

1488
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,16 @@ use Siteworxpro\App\Helpers\Env;
return [
'app' => [
'log_level' => Env::get('LOG_LEVEL', 'debug'),
'dev_mode' => Env::get('DEV_MODE', false, 'bool'),
],
/**
* The server configuration.
*/
'server' => [
'port' => Env::get('HTTP_PORT', 9501, 'int'),
'dev_mode' => Env::get('DEV_MODE', false, 'bool'),
],
/**
@@ -47,7 +51,7 @@ return [
'signing_key' => Env::get('JWT_SIGNING_KEY', 'a_super_secret_key'),
'audience' => Env::get('JWT_AUDIENCE', 'my_audience'),
'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' => [

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();
exit($server->start());
} catch (\Exception $e) {
echo $e->getMessage();
exit(1);
}

187
makefile Normal file
View File

@@ -0,0 +1,187 @@
# Makefile (enhanced)
SHELL := /bin/sh
.DEFAULT_GOAL := help
# Docker Compose file
COMPOSE_FILE := -f .dev/docker-compose.yml
# Reusable vars
DOCKER := docker compose $(COMPOSE_FILE)
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
DEV := $(DOCKER) exec $(DEV_RUNTIME) sh -c
COMPOSER := $(DOCKER) exec $(COMPOSER_RUNTIME) sh -c
# Colors
GREEN := \033[32m
YELLOW := \033[33m
RESET := \033[0m
# Fancy emoji
SPARK :=
ROCKET := 🚀
WARN := ⚠️
MAGNIFY := 🔍
BUG := 🐞
COMPOSE := 🐳
TRASH := 🧹
PROTO := 🧩
CHECK :=
CROSS :=
# Align width for help display
HELP_COL_WIDTH := 26
# Help: auto-generate from targets with "##" comments
help: ## Show this help
@echo "$(SPARK) Available commands:"
@awk -F':|##' '/^[a-zA-Z0-9._-]+:.*##/ {printf " %-$(HELP_COL_WIDTH)s - %s\n", $$1, $$3}' $(MAKEFILE_LIST) | sort
docker-build: ## Build Docker image
@push_flag=""; \
if [ -n "$(push)" ]; then \
push="--push"; \
fi
@if [ -z "$(image)" ]; then \
echo "image variable is required: make docker-build image=your-image-name tag=your"; \
exit 1; \
fi
@if [ -z "$(tag)" ]; then \
echo "tag variable is required: make docker-build image=your-image-name tag=your"; \
exit 1; \
fi
@platform_flag=""; \
if [ -n "$(platform)" ]; then \
platform_flag="--platform=$(platform)"; \
fi; \
printf "$(YELLOW)$(SPARK) Building Docker image: $(image):$(tag)$(RESET)\n"; \
docker buildx build $$platform_flag --provenance=true --sbom=true $$push_flag --tag $(image):$(tag) .
start: ## Start the development runtime container
@printf "$(GREEN)$(ROCKET) 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)$(WARN) 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)$(SPARK) Rebuilding containers$(RESET)\n"
@$(MAKE) stop
@printf "$(YELLOW)$(TRASH) Deleting all Docker resources$(RESET)\n"
docker system prune --all --volumes --force
@$(MAKE) start
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
@printf "$(COMPOSE) $(GREEN)Installing PHP dependencies in $(COMPOSER_RUNTIME)$(RESET)\n"
@$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate
$(COMPOSER) "composer install --no-interaction --prefer-dist --optimize-autoloader --ignore-platform-reqs"
composer-install-no-dev: ## Install PHP dependencies without dev packages in the composer runtime container
@printf "$(COMPOSE) $(GREEN)Installing PHP dependencies (no-dev) in $(COMPOSER_RUNTIME)$(RESET)\n"
@$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate
$(COMPOSER) "composer install --no-dev --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
@printf "$(COMPOSE) $(MAGNIFY) Requiring package $(package) in $(COMPOSER_RUNTIME)$(RESET)\n"
@$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate
$(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
@printf "$(COMPOSE) $(MAGNIFY) Requiring dev package $(package) in $(COMPOSER_RUNTIME)$(RESET)\n"
@$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate
$(COMPOSER) "composer require --dev $(package) --ignore-platform-reqs"
composer-update: ## Update PHP dependencies in the composer runtime container
@printf "$(COMPOSE) $(MAGNIFY) Updating PHP dependencies$(RESET)\n"
@$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate
$(COMPOSER) "composer update --no-interaction --prefer-dist --optimize-autoloader --ignore-platform-reqs"
enable-debug: ## Enable Xdebug in the development runtime container
@$(DOCKER) up $(DEV_RUNTIME) -d --no-recreate
@printf "$(GREEN)$(BUG) Enabling Xdebug in $(DEV_RUNTIME)$(RESET)\n"
$(DEV) "bin/xdebug.sh"
enable-coverage: ## Enable PCOV code coverage in the composer runtime container
@printf "$(GREEN)$(MAGNIFY) Enabling PCOV in $(COMPOSER_RUNTIME)$(RESET)\n"
@$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate
$(COMPOSER) "bin/pcov.sh"
protoc: ## Generate PHP gRPC code from .proto files
@printf "$(PROTO) $(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 "$(PROTO) $(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 "$(TRASH) $(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
license-check: ## Check license headers in source files
@printf "$(MAGNIFY) $(GREEN)Checking license headers$(RESET)\n"
@$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate
$(COMPOSER) "composer run-script tests:license || true"
# Developer tasks
lint: ## Run linting (phpcs/phpstan) in composer runtime
@printf "$(MAGNIFY) $(GREEN)Running linters$(RESET)\n"
@$(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)
@printf "$(MAGNIFY) $(GREEN)Formatting code$(RESET)\n"
@$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate
$(COMPOSER) "composer run-script tests:lint:fix"
test: ## Run test suite (phpunit)
@printf "$(CHECK) $(GREEN)Running unit tests$(RESET)\n"
@$(DOCKER) up $(COMPOSER_RUNTIME) -d
$(COMPOSER) "composer run-script tests:unit || true"
test-coverage: ## Run test suite with coverage report
@printf "$(CHECK) $(GREEN)Running unit tests with coverage report$(RESET)\n"
@$(DOCKER) up $(COMPOSER_RUNTIME) -d
@$(MAKE) enable-coverage
$(COMPOSER) "composer run-script tests:unit:coverage || true"
# Convenience aliases
dev: run ## Alias for start
ci: composer-install migrate license-check lint test ## CI-like local flow
down: stop ## Alias for stop
up: start ## Alias for start
.PHONY: help start sh run stop docker-build restart rebuild ps migrate composer-install composer-require composer-require-dev composer-update enable-debug enable-coverage protoc license-check lint fmt test test-coverage dev ci down up

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';
try {
// Instantiate the ExternalServer class
$server = new Api();
// Start the server
$server->startServer();
} catch (JsonException $e) {
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\NotFoundException;
use League\Route\RouteGroup;
use League\Route\Router;
use Nyholm\Psr7\Factory\Psr17Factory;
use Siteworxpro\App\Controllers\HealthcheckController;
use Siteworxpro\App\Controllers\IndexController;
use Siteworxpro\App\Controllers\OpenApiController;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Middleware\CorsMiddleware;
use Siteworxpro\App\Http\Middleware\JwtMiddleware;
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\Logger;
use Siteworxpro\HttpStatus\CodesEnum;
use Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\Worker;
@@ -69,8 +72,14 @@ class Api
$this->router = new Router();
$this->router->get('/', IndexController::class . '::get');
$this->router->post('/', IndexController::class . '::post');
$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 JwtMiddleware());
$this->router->middleware(new ScopeMiddleware());
@@ -104,28 +113,20 @@ class Api
$response = $this->router->handle($request);
$this->worker->respond($response);
} catch (MethodNotAllowedException | NotFoundException) {
$uri = '';
if (isset($request)) {
$uri = $request->getUri()->getPath();
}
$this->worker->respond(
JsonResponseFactory::createJsonResponse(
['status_code' => 404, 'reason_phrase' => 'Not Found'],
CodesEnum::NOT_FOUND
)
JsonResponseFactory::createJsonResponse(new NotFoundResponse($uri))
);
} catch (\Throwable $e) {
Logger::error($e->getMessage());
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(
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;
use Siteworxpro\App\Annotations\Async\HandlesMessage;
use Siteworxpro\App\Attributes\Async\HandlesMessage;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Services\Facades\Broker;

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
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\SayHelloMessage;
use Siteworxpro\App\Services\Facades\Logger;

View File

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

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\CommandBus;
use Attribute;
/**
* Class HandlesCommand
* @package Siteworxpro\App\Attributes\CommandBus
*/
#[Attribute(Attribute::TARGET_CLASS)]
readonly class HandlesCommand
{
/**
* @param class-string $commandClass
*/
public function __construct(public string $commandClass)
{
}
}

View File

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

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Siteworxpro\App\Annotations\Guards;
namespace Siteworxpro\App\Attributes\Guards;
use Attribute;
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.
*

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\Queue\Start;
use Siteworxpro\App\Cli\Commands\Queue\TestJob;
use Siteworxpro\App\Helpers\Version;
use Siteworxpro\App\Kernel;
use Siteworxpro\App\Services\Facades\Config;
@@ -21,7 +22,7 @@ class App
public function __construct()
{
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 Start());

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands;
use Ahc\Cli\Input\Command;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\Services\Facades\CommandBus;
class DemoCommand extends Command implements CommandInterface
{
@@ -28,18 +30,14 @@ class DemoCommand extends Command implements CommandInterface
$pb->finish();
$this->writer()->boldBlue("Demo Command Executed!\n");
if ($this->values()['name']) {
$name = $this->values()['name'];
$greet = $this->values()['greet'] ?? false;
} else {
return 0;
}
if ($greet) {
$this->writer()->green("Hello, $name! Welcome to the CLI demo.\n");
} else {
$this->writer()->yellow("Name provided: {$name}\n");
$exampleCommand = new ExampleCommand($name);
$this->writer()->yellow(CommandBus::handle($exampleCommand));
}
return 0;

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus;
use League\Tactician\Exception\CanNotInvokeHandlerException;
use League\Tactician\Handler\Locator\HandlerLocator;
use Siteworxpro\App\Attributes\CommandBus\HandlesCommand;
class AttributeLocator implements HandlerLocator
{
private const string HANDLER_NAMESPACE = 'Siteworxpro\\App\\CommandBus\\Handlers\\';
private array $handlers;
public function __construct()
{
$directory = __DIR__ . '/Handlers';
$files = scandir($directory);
foreach ($files as $file) {
if (pathinfo($file, PATHINFO_EXTENSION) === 'php') {
$className = pathinfo($file, PATHINFO_FILENAME);
$fullClassName = self::HANDLER_NAMESPACE . $className;
if (class_exists($fullClassName)) {
$reflectionClass = new \ReflectionClass($fullClassName);
$attributes = $reflectionClass->getAttributes(HandlesCommand::class);
foreach ($attributes as $attribute) {
$instance = $attribute->newInstance();
$commandClass = $instance->commandClass;
$this->handlers[$commandClass] = $fullClassName;
}
}
}
}
}
public function getHandlerForCommand($commandName)
{
if (isset($this->handlers[$commandName])) {
$handlerClass = $this->handlers[$commandName];
return new $handlerClass();
}
throw new CanNotInvokeHandlerException("No handler found for command: " . $commandName);
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Commands;
readonly abstract class Command
{
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Commands;
readonly class ExampleCommand extends Command
{
public function __construct(
private string $name
) {
}
public function getName(): string
{
return $this->name;
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
abstract class CommandHandler implements CommandHandlerInterface
{
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
use Siteworxpro\App\CommandBus\Commands\Command;
interface CommandHandlerInterface
{
public function __invoke(Command $command): mixed;
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
use Siteworxpro\App\Attributes\CommandBus\HandlesCommand;
use Siteworxpro\App\CommandBus\Commands\Command;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\Services\Facades\Logger;
#[HandlesCommand(ExampleCommand::class)]
class ExampleHandler extends CommandHandler
{
/**
* @param Command|ExampleCommand $command
* @return string
*/
public function __invoke(Command|ExampleCommand $command): string
{
if (!method_exists($command, 'getName')) {
throw new \TypeError('Invalid command type provided to ExampleHandler.');
}
$name = $command->getName();
Logger::info('Handling ExampleCommand for name: ' . $name);
return 'Hello, ' . $name . '!';
}
}

View File

@@ -6,7 +6,9 @@ namespace Siteworxpro\App\Controllers;
use League\Route\Http\Exception\NotFoundException;
use Nyholm\Psr7\ServerRequest;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Helpers\Version;
/**
* Class Controller
@@ -15,6 +17,18 @@ use Psr\Http\Message\ResponseInterface;
*
* @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
{
/**

View File

@@ -8,9 +8,13 @@ use Illuminate\Database\PostgresConnection;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\GenericResponse;
use Siteworxpro\App\Http\Responses\ServerErrorResponse;
use Siteworxpro\App\Models\Model;
use Siteworxpro\App\Services\Facades\Logger;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum;
use OpenApi\Attributes as OA;
/**
* Class HealthcheckController
@@ -22,8 +26,13 @@ use Siteworxpro\HttpStatus\CodesEnum;
class HealthcheckController extends Controller
{
/**
* Handles the GET request for health check.
*
* @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
{
try {
@@ -36,18 +45,19 @@ class HealthcheckController extends Controller
throw new \Exception('Redis ping failed');
}
} catch (\Exception $e) {
Logger::emergency(
'Healthcheck failed: ' . $e->getMessage(),
['exception' => $e]
);
return JsonResponseFactory::createJsonResponse(
[
'status_code' => CodesEnum::SERVICE_UNAVAILABLE->value,
'message' => 'Healthcheck Failed',
'error' => $e->getMessage(),
],
new ServerErrorResponse($e),
CodesEnum::SERVICE_UNAVAILABLE
);
}
return JsonResponseFactory::createJsonResponse(
['status_code' => 200, 'message' => 'Healthcheck OK']
new GenericResponse('Healthcheck OK')
);
}
}

View File

@@ -6,8 +6,14 @@ namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Annotations\Guards;
use Siteworxpro\App\Attributes\Guards;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\Docs\TokenSecurity;
use Siteworxpro\App\Docs\UnauthorizedResponse;
use Siteworxpro\App\Http\JsonResponseFactory;
use OpenApi\Attributes as OA;
use Siteworxpro\App\Http\Responses\GenericResponse;
use Siteworxpro\App\Services\Facades\CommandBus;
/**
* Class IndexController
@@ -22,19 +28,39 @@ class IndexController extends Controller
* @throws \JsonException
*/
#[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
{
return JsonResponseFactory::createJsonResponse(['status_code' => 200, 'message' => 'Server is running']);
$command = new ExampleCommand($request->getQueryParams()['name'] ?? 'Guest');
$greeting = CommandBus::handle($command);
return JsonResponseFactory::createJsonResponse(new GenericResponse('Server is running. ' . $greeting));
}
/**
* Handles the POST request for the index route.
*
* @throws \JsonException
*/
#[Guards\Jwt]
#[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
{
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,25 @@
<?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: '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\Support\Arrayable;
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
@@ -29,6 +32,8 @@ class Dispatcher implements DispatcherContract, Arrayable
*/
private Collection $pushed;
private array $subscribers = [];
/**
* @var string LISTENERS_NAMESPACE The namespace where listeners are located
*/
@@ -40,6 +45,16 @@ class Dispatcher implements DispatcherContract, Arrayable
$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.
*
@@ -99,7 +114,7 @@ class Dispatcher implements DispatcherContract, Arrayable
*/
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 array $payload
* @return array|null
* @throws \Throwable
*/
public function until($event, $payload = []): array|null
{
@@ -121,6 +137,7 @@ class Dispatcher implements DispatcherContract, Arrayable
* @param array $payload
* @param bool $halt
* @return array|null
* @throws \Throwable
*/
public function dispatch($event, $payload = [], $halt = false): array|null
{
@@ -130,23 +147,46 @@ class Dispatcher implements DispatcherContract, Arrayable
$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;
// If no listeners, just await the subscriber promise
if ($listeners === null) {
return null;
return await($promise);
}
$responses = [];
foreach ($listeners as $listener) {
$response = $listener($event, $payload);
$responses[] = $response;
$responses[$eventClass] = $response;
if ($halt && $response !== null) {
return $response;
}
}
// Await the subscriber promise and merge responses
$promiseResponses = await($promise);
if (is_array($promiseResponses)) {
$responses = array_merge($responses, $promiseResponses);
}
return $responses;
}
@@ -167,6 +207,7 @@ class Dispatcher implements DispatcherContract, Arrayable
*
* @param $event
* @return void
* @throws \Throwable
*/
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\ConnectionEvent;
use Siteworxpro\App\Annotations\Events\ListensFor;
use Siteworxpro\App\Attributes\Events\ListensFor;
use Siteworxpro\App\Events\Listeners\Listener;
use Siteworxpro\App\Services\Facades\Logger;
@@ -18,12 +18,15 @@ use Siteworxpro\App\Services\Facades\Logger;
class Connected extends Listener
{
/**
* @param ConnectionEvent $event
* @param mixed $event
* @param array $payload
* @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]);

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;
}

54
src/Grpc.php Normal file
View File

@@ -0,0 +1,54 @@
<?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
{
private const array SERVICES = [
GreeterInterface::class => GreeterHandler::class,
];
/**
* @throws \ReflectionException
*/
public function __construct()
{
Kernel::boot();
}
/**
* Starts the gRPC server
*
* @return int
*/
public function start(): int
{
$server = new Server(new Invoker(), [
'debug' => Config::get('app.dev_mode'),
]);
foreach (self::SERVICES as $interface => $handler) {
$server->registerService($interface, new $handler());
}
$server->serve(Worker::create());
return 0;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\GrpcHandlers;
use GRPC\Greeter\GreeterInterface;
use GRPC\Greeter\HelloReply;
use GRPC\Greeter\HelloRequest;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\Services\Facades\CommandBus;
use Spiral\RoadRunner\GRPC;
class GreeterHandler implements GreeterInterface
{
public function SayHello(GRPC\ContextInterface $ctx, HelloRequest $in): HelloReply // phpcs:ignore
{
$command = new ExampleCommand($in->getName());
$reply = new HelloReply();
$reply->setMessage(CommandBus::handle($command));
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;
use Illuminate\Contracts\Support\Arrayable;
use Nyholm\Psr7\Response;
use Siteworxpro\HttpStatus\CodesEnum;
@@ -17,13 +18,19 @@ class JsonResponseFactory
/**
* 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.
* @return Response The JSON response.
* @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(
status: $statusCode->value,
headers: [

View File

@@ -6,8 +6,10 @@ namespace Siteworxpro\App\Http\Middleware;
use Carbon\Carbon;
use Carbon\WrapperClock;
use GuzzleHttp\Exception\GuzzleException;
use Lcobucci\JWT\JwtFacade;
use Lcobucci\JWT\Signer\Hmac\Sha256 as Hmac256;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\InvalidTokenStructure;
@@ -21,10 +23,12 @@ use League\Route\Dispatcher;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
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\Http\JsonResponseFactory;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Guzzle;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum;
/**
@@ -103,7 +107,7 @@ class JwtMiddleware extends Middleware
/** @var Jwt $jwtInstance */
$jwtInstance = $attribute->newInstance();
if ($jwtInstance->getRequiredAudience() !== '') {
if ($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.
$jwt = new JwtFacade()->parse(
$token,
$this->getSignedWith(),
$this->getSignedWith($token),
Config::get('jwt.strict_validation') ?
new StrictValidAt(new WrapperClock(Carbon::now())) :
new LooseValidAt(new WrapperClock(Carbon::now())),
@@ -129,16 +133,21 @@ class JwtMiddleware extends Middleware
}
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
'status_code' => CodesEnum::UNAUTHORIZED->value,
'message' => 'Unauthorized: Invalid token',
'errors' => $violations
], CodesEnum::UNAUTHORIZED);
} catch (InvalidTokenStructure) {
// Token could not be parsed due to malformed structure.
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
'status_code' => CodesEnum::UNAUTHORIZED->value,
'message' => 'Unauthorized: Invalid token',
], 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.
@@ -161,20 +170,30 @@ class JwtMiddleware extends Middleware
* @return SignedWith Signature constraint used during JWT parsing.
*
* @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.');
}
// Load key either from file or raw text.
if (str_starts_with($key, 'file://')) {
$key = InMemory::file(substr($key, 7));
// file:// path to key
if (str_starts_with($keyConfig, 'file://')) {
$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 {
$key = InMemory::plainText($key);
$key = InMemory::plainText($keyConfig);
}
// 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);
}
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\ServerRequestInterface;
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\Http\JsonResponseFactory;
use Siteworxpro\HttpStatus\CodesEnum;
@@ -42,7 +43,7 @@ class ScopeMiddleware extends Middleware
*/
public function process(
ServerRequestInterface $request,
RequestHandlerInterface | Dispatcher $handler
RequestHandlerInterface|Dispatcher $handler
): ResponseInterface {
// Attempt to resolve the route's callable [Controller instance, method name].
$callable = $this->extractRouteCallable($handler);
@@ -57,26 +58,49 @@ class ScopeMiddleware extends Middleware
// Ensure the controller exists and the method is defined before reflecting.
if (class_exists($class::class)) {
$reflectionClass = new \ReflectionClass($class);
if ($reflectionClass->hasMethod($method)) {
$reflectionMethod = $reflectionClass->getMethod($method);
// Fetch all Scope attributes declared on the method.
$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) {
/** @var Scope $scopeInstance Concrete Scope attribute instance. */
$scopeInstance = $attribute->newInstance();
$requiredScopes = $scopeInstance->getScopes();
$requiredScopes = array_merge($requiredScopes, $scopeInstance->getScopes());
// Retrieve user scopes from the request (defaults to an empty array).
$userScopes = $request->getAttribute('scopes', []);
// If any attribute requires all scopes, set the flag.
$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.
if (
array_any(
$requiredScopes,
fn($requiredScope) => !in_array($requiredScope, $userScopes, true)
)
(!$requireAll && array_intersect($userScopes, $requiredScopes) === []) ||
($requireAll && array_diff($requiredScopes, $userScopes) !== [])
) {
return JsonResponseFactory::createJsonResponse([
'error' => 'insufficient_scope',
@@ -86,7 +110,6 @@ class ScopeMiddleware extends Middleware
}
}
}
}
// All checks passed; continue down the middleware pipeline.
return $handler->handle($request);

View File

@@ -0,0 +1,29 @@
<?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.'),
]
)]
readonly class GenericResponse implements Arrayable
{
public function __construct(
private string $message = '',
) {
}
public function toArray(): array
{
return [
'message' => $this->message,
];
}
}

View File

@@ -0,0 +1,38 @@
<?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: '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 [
'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: '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 [
'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 [
'code' => $this->e->getCode() != 0 ?
$this->e->getCode() :
CodesEnum::INTERNAL_SERVER_ERROR->value,
'message' => 'An internal server error occurred.',
];
}
}

View File

@@ -10,6 +10,7 @@ use Siteworxpro\App\Services\Facade;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Dispatcher;
use Siteworxpro\App\Services\ServiceProviders\BrokerServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\CommandBusProvider;
use Siteworxpro\App\Services\ServiceProviders\DispatcherServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\LoggerServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\RedisServiceProvider;
@@ -33,7 +34,8 @@ class Kernel
LoggerServiceProvider::class,
RedisServiceProvider::class,
DispatcherServiceProvider::class,
BrokerServiceProvider::class
BrokerServiceProvider::class,
CommandBusProvider::class,
];
/**

View File

@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace Siteworxpro\App\Models;
use Carbon\Carbon;
use OpenApi\Attributes as OA;
use Siteworxpro\App\Helpers\Ulid;
/**
* Class User
*
* @property string $id
* @property-read string $id
* @property string $first_name
* @property string $last_name
* @property string $email
@@ -19,6 +21,23 @@ use Carbon\Carbon;
* @property-read string $full_name
* @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
{
protected $casts = [
@@ -36,6 +55,12 @@ class User extends Model
'password',
];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->attributes['id'] = $this->attributes['id'] ?? Ulid::generate();
}
public function getFullNameAttribute(): string
{
return "$this->first_name $this->last_name";

View File

@@ -55,24 +55,6 @@ class Facade
});
}
/**
* Convert the facade into a Mockery spy.
*
* @return HigherOrderTapProxy | MockInterface
*/
public static function spy(): HigherOrderTapProxy | MockInterface
{
if (! static::isMock()) {
$class = static::getMockableClass();
return tap($class ? Mockery::spy($class) : Mockery::spy(), function ($spy) {
static::swap($spy);
});
}
throw new \RuntimeException('Cannot spy on an existing mock instance.');
}
/**
* Initiate a partial mock on the facade.
*
@@ -189,19 +171,6 @@ class Facade
}
}
/**
* Determines whether a "fake" has been set as the facade instance.
*
* @return bool
*/
public static function isFake(): bool
{
$name = static::getFacadeAccessor();
return isset(static::$resolvedInstance[$name]) &&
static::$resolvedInstance[$name] instanceof Fake;
}
/**
* Get the root object behind the facade.
*

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use Siteworxpro\App\CommandBus\Commands\Command;
use Siteworxpro\App\Services\Facade;
/**
* Broker Facade
*
* @package Siteworxpro\App\Services\Facades
* @method static mixed handle(Command $command)
*/
class CommandBus extends Facade
{
/**
* Get the registered name of the component.
*
* @return string The name of the component.
*/
protected static function getFacadeAccessor(): string
{
return \League\Tactician\CommandBus::class;
}
}

View File

@@ -14,6 +14,7 @@ use Siteworxpro\App\Services\Facade;
* 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 void set(string $key, mixed $value) Set the configuration value for the given key. // @codingStandardsIgnoreEnd
*
* @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,10 @@ namespace Siteworxpro\App\Services\ServiceProviders;
use Illuminate\Support\ServiceProvider;
use Siteworxpro\App\Async\Brokers\Broker;
use Siteworxpro\App\Async\Brokers\Kafka;
use Siteworxpro\App\Async\Brokers\RabbitMQ;
use Siteworxpro\App\Async\Brokers\Redis;
use Siteworxpro\App\Async\Brokers\Sqs;
use Siteworxpro\App\Services\Facades\Config;
/**
@@ -18,6 +22,11 @@ use Siteworxpro\App\Services\Facades\Config;
*/
class BrokerServiceProvider extends ServiceProvider
{
public function provides(): array
{
return [Kafka::class, RabbitMQ::class, Redis::class, Sqs::class];
}
/**
* Register services.
*

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\ServiceProviders;
use Illuminate\Support\ServiceProvider;
use League\Tactician\CommandBus;
use League\Tactician\Handler\CommandHandlerMiddleware;
use League\Tactician\Handler\CommandNameExtractor\ClassNameExtractor;
use League\Tactician\Handler\MethodNameInflector\InvokeInflector;
use Siteworxpro\App\CommandBus\AttributeLocator;
/**
* Class CommandBusProvider
*
* @package Siteworxpro\App\Services\ServiceProviders
*/
class CommandBusProvider extends ServiceProvider
{
public function provides(): array
{
return [CommandBus::class];
}
public function register(): void
{
$this->app->singleton(CommandBus::class, function () {
return new CommandBus([
new CommandHandlerMiddleware(
new ClassNameExtractor(),
new AttributeLocator(),
new InvokeInflector()
),
]);
});
}
}

View File

@@ -14,6 +14,11 @@ use Siteworxpro\App\Events\Dispatcher;
*/
class DispatcherServiceProvider extends ServiceProvider
{
public function provides(): array
{
return [Dispatcher::class];
}
public function register(): void
{
$this->app->singleton(Dispatcher::class, function () {

View File

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

View File

@@ -17,6 +17,11 @@ use Siteworxpro\App\Services\Facades\Config;
*/
class RedisServiceProvider extends ServiceProvider
{
public function provides(): array
{
return [Client::class];
}
public function register(): void
{
$this->app->singleton(Client::class, function () {

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

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\CommandBus;
use League\Tactician\Exception\CanNotInvokeHandlerException;
use Siteworxpro\App\CommandBus\AttributeLocator;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\CommandBus\Handlers\ExampleHandler;
use Siteworxpro\Tests\Unit;
class AttributeLocatorTest extends Unit
{
private const array HANDLERS = [
ExampleCommand::class => ExampleHandler::class,
];
public function testResolvesFiles(): void
{
$attributeLocator = new AttributeLocator();
foreach (self::HANDLERS as $command => $handler) {
$class = $attributeLocator->getHandlerForCommand($command);
$this->assertInstanceOf($handler, $class);
}
}
public function testThrowsOnCannotResolve(): void
{
$attributeLocator = new AttributeLocator();
$this->expectException(CanNotInvokeHandlerException::class);
$attributeLocator->getHandlerForCommand('NonExistentCommand');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Siteworxpro\Tests\CommandBus\Handlers;
use Siteworxpro\App\CommandBus\Commands\Command;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\CommandBus\Handlers\ExampleHandler;
use Siteworxpro\Tests\Unit;
class ExampleHandlerTest extends Unit
{
public function testExampleCommand(): void
{
$command = new ExampleCommand('test payload');
$this->assertEquals('test payload', $command->getName());
$handler = new ExampleHandler();
$result = $handler($command);
$this->assertEquals('Hello, test payload!', $result);
}
public function testThrowsException(): void
{
$class = new readonly class extends Command
{
};
$this->expectException(\TypeError::class);
$handler = new ExampleHandler();
$handler($class);
}
}

View File

@@ -4,22 +4,45 @@ declare(strict_types=1);
namespace Siteworxpro\Tests\Controllers;
use League\Tactician\CommandBus;
use Siteworxpro\App\Controllers\IndexController;
class IndexControllerTest extends AbstractController
{
/**
* @throws \JsonException
* @throws \JsonException|\ReflectionException
*/
public function testGet(): void
{
$this->assertTrue(true);
$this->getContainer()->bind(CommandBus::class, function () {
return \Mockery::mock(CommandBus::class)
->shouldReceive('handle')
->andReturn('Hello World')
->getMock();
});
$controller = new IndexController();
$response = $controller->get($this->getMockRequest());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('{"status_code":200,"message":"Server is running"}', (string)$response->getBody());
$this->assertEquals('{"message":"Server is running. Hello World"}', (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"}', (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

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\GrpcHandlers;
use GRPC\Greeter\HelloRequest;
use League\Tactician\CommandBus;
use Siteworxpro\App\GrpcHandlers\GreeterHandler;
use Siteworxpro\Tests\Unit;
use Spiral\RoadRunner\GRPC\ContextInterface;
class GreeterHandlerTest extends Unit
{
/**
* @throws \ReflectionException
*/
public function testSayHello(): void
{
$this->getContainer()->bind(CommandBus::class, function () {
return \Mockery::mock(CommandBus::class)
->shouldReceive('handle')
->andReturn('Hello World')
->getMock();
});
$request = new HelloRequest();
$request->setName('World');
$context = \Mockery::mock(ContextInterface::class);
$handler = new GreeterHandler();
$response = $handler->SayHello($context, $request);
$this->assertEquals('Hello World', $response->getMessage());
}
}

View File

@@ -11,7 +11,7 @@ use Siteworxpro\App\Http\Middleware\CorsMiddleware;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\Tests\Unit;
class CorsMiddlewareTest extends Unit
class CorsMiddlewareTest extends Middleware
{
public function testAllowsConfiguredOrigin(): void
{
@@ -80,22 +80,4 @@ class CorsMiddlewareTest extends Unit
$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,21 @@
<?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 = [
'message' => 'The requested resource /api/resource was not found.',
'context' => ['key' => 'value'],
];
$this->assertEquals($expected, $response->toArray());
}
}

View File

@@ -0,0 +1,93 @@
<?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 = [
'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
{
Config::set('app.dev_mode', false);
try {
throw new \Exception('A Test Error occurred.');
} catch (\Exception $exception) {
$response = new ServerErrorResponse($exception);
$expected = [
'code' => 500,
'message' => 'An internal server error occurred.',
];
$this->assertEquals($expected, $response->toArray());
}
}
public function testToArrayIfCodeIsSet(): void
{
Config::set('app.dev_mode', false);
try {
throw new \Exception('A Test Error occurred.', 1234);
} catch (\Exception $exception) {
$response = new ServerErrorResponse($exception);
$expected = [
'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 = [
'code' => 1234,
'message' => 'A Test Error occurred.',
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTrace(),
'context' => [],
];
$this->assertEquals($expected, $response->toArray());
}
}
}

View File

@@ -9,7 +9,9 @@ use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use RoadRunner\Logger\Logger as RRLogger;
use Siteworxpro\App\Log\Logger;
use Siteworxpro\App\Services\Facades\Logger as LoggerFacade;
use Siteworxpro\Tests\Unit;
class LoggerRpcTest extends Unit
@@ -34,10 +36,10 @@ class LoggerRpcTest extends Unit
$mock = Mockery::mock(LoggerInterface::class);
$mock->expects('debug')
->with('message', ['key' => 'value'])
->once();
->times(1);
\Siteworxpro\App\Services\Facades\Logger::getFacadeContainer()
->bind(\RoadRunner\Logger\Logger::class, function () use ($mock) {
LoggerFacade::getFacadeContainer()
->bind(RRLogger::class, function () use ($mock) {
return $mock;
});
@@ -46,8 +48,6 @@ class LoggerRpcTest extends Unit
$logger->debug('message', ['key' => 'value']);
$mock->shouldHaveReceived('debug');
Mockery::close();
}
/**
@@ -65,8 +65,8 @@ class LoggerRpcTest extends Unit
->with('message', ['key' => 'value'])
->times(2);
\Siteworxpro\App\Services\Facades\Logger::getFacadeContainer()
->bind(\RoadRunner\Logger\Logger::class, function () use ($mock) {
LoggerFacade::getFacadeContainer()
->bind(RRLogger::class, function () use ($mock) {
return $mock;
});
@@ -76,7 +76,6 @@ class LoggerRpcTest extends Unit
$logger->notice('message', ['key' => 'value']);
$mock->shouldHaveReceived('info')->times(2);
Mockery::close();
}
/**
@@ -94,8 +93,8 @@ class LoggerRpcTest extends Unit
->with('message', ['key' => 'value'])
->times(1);
\Siteworxpro\App\Services\Facades\Logger::getFacadeContainer()
->bind(\RoadRunner\Logger\Logger::class, function () use ($mock) {
LoggerFacade::getFacadeContainer()
->bind(RRLogger::class, function () use ($mock) {
return $mock;
});
@@ -104,7 +103,6 @@ class LoggerRpcTest extends Unit
$logger->warning('message', ['key' => 'value']);
$mock->shouldHaveReceived('warning');
Mockery::close();
}
/**
@@ -122,8 +120,8 @@ class LoggerRpcTest extends Unit
->with('message', ['key' => 'value'])
->times(4);
\Siteworxpro\App\Services\Facades\Logger::getFacadeContainer()
->bind(\RoadRunner\Logger\Logger::class, function () use ($mock) {
LoggerFacade::getFacadeContainer()
->bind(RRLogger::class, function () use ($mock) {
return $mock;
});
@@ -135,7 +133,6 @@ class LoggerRpcTest extends Unit
$logger->emergency('message', ['key' => 'value']);
$mock->shouldHaveReceived('error')->times(4);
Mockery::close();
}
/**
@@ -152,8 +149,8 @@ class LoggerRpcTest extends Unit
$mock->expects('log')
->with('notaloglevel', 'message', ['key' => 'value']);
\Siteworxpro\App\Services\Facades\Logger::getFacadeContainer()
->bind(\RoadRunner\Logger\Logger::class, function () use ($mock) {
LoggerFacade::getFacadeContainer()
->bind(RRLogger::class, function () use ($mock) {
return $mock;
});
@@ -162,6 +159,5 @@ class LoggerRpcTest extends Unit
$logger->log('notaloglevel', 'message', ['key' => 'value']);
$mock->shouldHaveReceived('log')->times(1);
Mockery::close();
}
}

View File

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

@@ -28,8 +28,9 @@ abstract class AbstractServiceProvider extends Unit
$this->assertInstanceOf($providerClass, $provider);
$provider->register();
$bindings = $provider->bindings;
foreach ($bindings as $abstract => $concrete) {
$abstract = $provider->provides()[0];
$concrete = get_class($container->make($abstract));
$this->assertTrue($container->bound($abstract), "The $abstract is not bound in the container.");
$this->assertNotNull($container->make($abstract), "The $abstract could not be resolved.");
@@ -39,5 +40,4 @@ abstract class AbstractServiceProvider extends Unit
"The $abstract is not an instance of $concrete."
);
}
}
}

View File

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

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,20 +5,34 @@ declare(strict_types=1);
namespace Siteworxpro\Tests;
use Illuminate\Container\Container;
use Mockery;
use PHPUnit\Framework\TestCase;
use Siteworx\Config\Config as SWConfig;
use Siteworxpro\App\Kernel;
use Siteworxpro\App\Services\Facade;
use Siteworxpro\App\Services\Facades\Config;
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
*/
protected function setUp(): void
{
$container = new Container();
Facade::setFacadeContainer($container);
$container = $this->getContainer();
$container->bind(SWConfig::class, function () {
return SWConfig::load(__DIR__ . '/../config.php');
@@ -29,5 +43,6 @@ abstract class Unit extends TestCase
{
Config::clearResolvedInstances();
Facade::setFacadeContainer(null);
Mockery::close();
}
}