70 Commits

Author SHA1 Message Date
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
7fe2722fc1 chore: add deployment configurations and tests for logger and dispatcher (#17)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m40s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m36s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m4s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m24s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m46s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m27s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 16m7s
🏗️✨ Build Workflow / 🖥️ 🔨 Build Migrations (push) Successful in 1m29s
Reviewed-on: #17
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-13 19:38:26 +00:00
5542ad1e75 chore: added documentation (#16)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m42s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m38s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m5s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m44s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m28s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m27s
Reviewed-on: #16
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-13 04:14:13 +00:00
e4a55af694 feat/queue-kafka (#15)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m26s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m8s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 59s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m14s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m16s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m13s
Reviewed-on: #15
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-12 20:29:42 +00:00
2879cbe203 feat: implement queue system with consumer and message handling (#14)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m1s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m16s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 3m13s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 3m5s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m11s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m51s
Reviewed-on: #14
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-12 12:00:31 +00:00
eeb46bc982 feat: implement custom event dispatcher and listener system (#13)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m38s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m38s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m40s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m52s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m51s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m21s
Reviewed-on: #13
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-11 16:12:19 +00:00
7d0b00fb89 feat/cli-framework (#12)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m37s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m32s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m54s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m46s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m49s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m18s
Reviewed-on: #12
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-11 14:52:29 +00:00
13445a0719 feat: implement JWT authentication and scope validation middleware (#11)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m50s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m41s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m8s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m22s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m5s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m41s
Reviewed-on: #11
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-07 17:14:22 +00:00
54ea22c49a chore: update RoadRunner and PHP versions in Dockerfile (#10)
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m23s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m14s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m31s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m37s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m27s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m44s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 10m37s
🏗️✨ Build Workflow / 🖥️ 🔨 Build Migrations (push) Successful in 1m33s
Reviewed-on: #10
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-07 04:36:49 +00:00
f8d3462cb7 added example model (#9)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m56s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m47s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m55s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m44s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m8s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m48s
Reviewed-on: #9
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-10-21 17:23:18 +00:00
68614958a9 feat: add migration container and healthchecks for services (#8)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m32s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m31s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m41s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m50s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m40s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m37s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 12m3s
🏗️✨ Build Workflow / 🖥️ 🔨 Build Migrations (push) Successful in 1m36s
Reviewed-on: #8
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-10-21 14:33:11 +00:00
413145f479 chore: update test configurations and ignore files for coverage reporting (#7)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m47s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m37s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m20s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m21s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m59s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m55s
Reviewed-on: #7
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-10-16 12:44:36 +00:00
d2bd9d2d1b chore/dev-env (#6)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m55s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m56s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m10s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m56s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m45s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 53s
Reviewed-on: #6
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-10-16 00:24:58 +00:00
78d5213892 feat/migrations-container (#5)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m34s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m24s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m38s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m49s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m41s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 56s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 12m39s
🏗️✨ Build Workflow / 🖥️ 🔨 Build Migrations (push) Successful in 1m35s
Reviewed-on: #5
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-10-15 15:59:17 +00:00
56b78f0102 refactor: update facade structure and add service providers for logging and Redis (#4)
All checks were successful
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m53s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m4s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m7s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m57s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m44s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 52s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 3m13s
Reviewed-on: #4
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-10-15 15:39:25 +00:00
05d8c5b813 fix-container-build (#3)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m11s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m29s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m27s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m15s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m20s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m26s
Reviewed-on: Siteworxpro/Php-Template#3
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-05-22 18:57:08 -04:00
2d6d0a43f3 The last time I tried this the monkey didn't survive. Let's hope it works better this time. (#2)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 6m44s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 6m53s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m35s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m29s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m39s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 4m3s
Reviewed-on: Siteworxpro/Php-Template#2
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-05-22 13:07:54 -04:00
e203975294 try our sister game minceraft! (#1)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m32s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m30s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m43s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m53s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m2s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m8s
Reviewed-on: Siteworxpro/Php-Template#1
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-05-14 12:45:28 -04:00
b1e5470a07 what is estonia up to now ...
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m55s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m56s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m4s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m48s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m2s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m5s
2025-05-14 12:04:19 -04:00
5f9ee939d6 Spinning up the hamster...
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m18s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m29s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m44s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m17s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m25s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 3m7s
2025-05-14 11:55:29 -04:00
e9a3a5885f Ok
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 1m26s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m40s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m17s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m35s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m0s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m38s
2025-05-14 11:50:02 -04:00
f12559d2c3 If it's stupid and it works, it ain't stupid
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 1m55s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m54s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m0s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m3s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m5s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m12s
2025-05-14 11:47:46 -04:00
b466495ae1 I am Root. We are Root.
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 1m5s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m46s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m52s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m30s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m6s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m45s
2025-05-14 11:44:22 -04:00
4eb21ace55 Does not work.
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 55s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m25s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m31s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m45s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m59s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 3m21s
2025-05-14 11:42:52 -04:00
a3e54ce989 these confounded tests drive me nuts
Some checks failed
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m2s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m5s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m7s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m10s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m11s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 3m16s
2025-05-14 11:37:15 -04:00
403cb4c12e Never Run This Commit As Root
Some checks failed
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m0s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m59s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m3s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m5s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m7s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Has been cancelled
2025-05-14 11:28:58 -04:00
9f048e408c How is the target directory over 100 gigs?
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 1m6s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m5s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m11s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m15s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Failing after 2m31s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 57s
2025-05-14 11:22:45 -04:00
be570a3173 eppic fail Mike
Some checks failed
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m14s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m18s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m17s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m29s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m36s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 2m48s
2025-05-14 11:15:40 -04:00
6353a4f19e speling is difikult
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m36s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m45s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 4m9s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 7m41s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 7m38s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 7m40s
2025-05-14 11:05:53 -04:00
59bcf4d2a7 Handled a particular error.
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 42s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m27s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m26s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m40s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m21s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m28s
2025-05-14 11:02:48 -04:00
89bc3e75ec It'd be nice if type errors caused the compiler to issue a type error
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 1m49s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m57s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m2s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m0s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m0s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m59s
2025-05-14 11:00:44 -04:00
fb04e9c390 Never gonna make you cry
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 2m19s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m55s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m51s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m2s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m58s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m3s
2025-05-14 10:53:51 -04:00
77d66abbe0 it is hump day _^_
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 1m18s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m1s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m1s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m2s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m59s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m5s
2025-05-14 10:52:20 -04:00
52aa7abc2d Landed.
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 1m51s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m58s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m2s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m1s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m0s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m2s
2025-05-14 10:37:10 -04:00
b6256a7e1d Automate Accounting
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 2m31s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m41s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m50s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m38s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m25s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m31s
2025-05-14 10:32:25 -04:00
59fa57d643 Moved something to somewhere... goodnight...
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 1m18s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m31s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m30s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m34s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m33s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m44s
2025-05-14 10:29:58 -04:00
199921fb83 Still can't get this right...
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 59s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m5s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m7s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m4s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m4s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m28s
2025-05-14 10:28:10 -04:00
aaf772dc6d apparently i did something…
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 1m13s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m27s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m24s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m17s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m39s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m36s
2025-05-14 10:25:33 -04:00
89fd169067 Never gonna tell a lie and hurt you
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 2m3s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m7s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m9s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m7s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m11s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m11s
2025-05-14 10:22:59 -04:00
a01a0c590b QuickFix.
Some checks failed
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m9s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m13s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m14s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m13s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m15s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 2m27s
2025-05-14 10:19:36 -04:00
dfccf9ca4f --help
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 1m51s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m3s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m1s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m50s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m11s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m5s
2025-05-14 10:16:47 -04:00
011a1e77c0 Use a real JS construct, WTF knows why this works in chromium.
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 9m49s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 9m47s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 9m52s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 10m17s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 10m25s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 10m30s
2025-05-14 10:06:28 -04:00
7c7b538616 Does this work
Some checks failed
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m2s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 2m6s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m5s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m1s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m5s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m10s
2025-05-14 10:00:22 -04:00
71fa18bc17 bla
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 1m2s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m58s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m3s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m7s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m5s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m4s
2025-05-14 09:57:56 -04:00
7308f33de4 TDD: 1, Me: 0
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 1m41s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m3s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m2s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m0s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m23s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 1m36s
2025-05-14 09:37:47 -04:00
d0d18fd603 Definitely fixing a mistake Copilot made. Totally not mine.
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m21s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 1m41s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m44s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m45s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m4s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m19s
2025-05-14 09:35:20 -04:00
f57406f981 Popping stash
Some checks failed
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m10s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m15s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m14s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 2m28s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m32s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m33s
2025-05-14 09:31:33 -04:00
e46bf5a6ec All your codebase are belong to us.
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 9m29s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 10m3s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 10m8s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 10m18s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 10m20s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 10m23s
2025-05-14 09:09:41 -04:00
02acc9afbe  - Temporary commit.
All checks were successful
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 6m52s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 6m54s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 7m19s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 7m22s
2025-05-14 07:37:54 -04:00
cdb2b28cb7 removed echo and die statements, lolz.
All checks were successful
🧪✨ Tests Workflow / License Check (push) Successful in 2m1s
🧪✨ Tests Workflow / Code Sniffer (push) Successful in 2m6s
🧪✨ Tests Workflow / Code Lint (push) Successful in 2m11s
🧪✨ Tests Workflow / Unit Tests (push) Successful in 2m49s
2025-05-13 21:32:03 -04:00
118e83414d Continued development... 2025-05-13 20:53:50 -04:00
d013641191 I expected something different.
All checks were successful
🧪✨ Tests Workflow / Code Lint (push) Successful in 1m29s
🧪✨ Tests Workflow / Code Sniffer (push) Successful in 1m56s
🧪✨ Tests Workflow / Unit Tests (push) Successful in 3m54s
🧪✨ Tests Workflow / License Check (push) Successful in 4m29s
2025-05-13 20:52:10 -04:00
a2d500d398 Merging 'WIP: Do Not Merge This Branch' Into Master
All checks were successful
🧪✨ Tests Workflow / Unit Tests (push) Successful in 3m7s
🧪✨ Tests Workflow / Code Lint (push) Successful in 3m33s
🧪✨ Tests Workflow / License Check (push) Successful in 3m36s
🧪✨ Tests Workflow / Code Sniffer (push) Successful in 3m18s
2025-05-13 20:47:53 -04:00
7694f29f78 fixed the israeli-palestinian conflict
Some checks failed
🧪✨ Tests Workflow / Code Lint (push) Has been cancelled
🧪✨ Tests Workflow / Unit Tests (push) Has been cancelled
🧪✨ Tests Workflow / License Check (push) Has been cancelled
🧪✨ Tests Workflow / Code Sniffer (push) Has been cancelled
2025-05-13 20:45:19 -04:00
c56a77d32c The last time I tried this the monkey didn't survive. Let's hope it works better this time.
All checks were successful
🧪✨ Tests Workflow / License Check (push) Successful in 1m39s
🧪✨ Tests Workflow / Code Lint (push) Successful in 1m19s
🧪✨ Tests Workflow / Code Sniffer (push) Successful in 1m26s
🧪✨ Tests Workflow / Unit Tests (push) Successful in 1m31s
2025-05-13 20:41:54 -04:00
120 changed files with 7981 additions and 893 deletions

View File

@@ -1,3 +1,5 @@
.idea/
.DS_Store
vendor/
.phpunit.cache/
tests/

View File

@@ -1,19 +1,17 @@
on:
create:
push:
tags:
- '*'
- "v*"
name: 🏗️✨ Build Workflow
jobs:
Build:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
name: 🖥️ 🔨 Build
runs-on: ubuntu-latest
steps:
- name: 🛡️ 🔒 Add Siteworx CA Certificates
run: |
apt update && apt install -yq ca-certificates curl
curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt
update-ca-certificates
@@ -28,12 +26,54 @@ 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
- name: 🐳🔨 Build Backend Container
- name: 🐳 🔨 Build Container
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
dockerfile: Dockerfile
tags: siteworxpro/template:${{ gitea.ref_name }}
Build-Migrations:
needs:
- Build
name: 🖥️ 🔨 Build Migrations
runs-on: ubuntu-latest
steps:
- name: 🛡️ 🔒 Add Siteworx CA Certificates
run: |
curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt
update-ca-certificates
- name: 🔑 🔐 Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: 📖 🔍 Checkout Repository Code
uses: actions/checkout@v2
with:
fetch-depth: 1
- name: 🏗️ 🔧 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🐳 🔨 Build Migrations Container
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
sbom: true
provenance: true
context: .
file: migrations.Dockerfile
tags: siteworxpro/template:${{ gitea.ref_name }}-migrations

View File

@@ -1,18 +1,19 @@
on:
push: {}
push:
branches:
- "**"
name: 🧪✨ Tests Workflow
jobs:
LicenseCheck:
name: License Check
DatabaseMigrations:
name: 🧪 ✨ Database Migrations
runs-on: ubuntu-latest
steps:
- name: 🛡️ 🔒 Add Siteworx CA Certificates
run: |
apt update && apt install -yq ca-certificates curl
curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt
update-ca-certificates
@@ -27,15 +28,114 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Install Composer Libraries
- name: 🏎️ 🏁 Start Support Containers
run: |
echo "Starting Support Containers"
docker run --rm \
--network "${{ env.JOB_CONTAINER_NAME }}-${{ gitea.job }}-network" \
--name ${{ gitea.job }}-${{ gitea.run_id }}-postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=postgres \
-p 5432 \
-d postgres:18
echo "Waiting for Postgres to start"
sleep 10
- name: 💽 ⬆️ Run Migrations
run: |
docker run \
--name ${{ gitea.run_id }}-migrate \
--rm \
--volumes-from ${{ env.JOB_CONTAINER_NAME }} \
--network "${{ env.JOB_CONTAINER_NAME }}-${{ gitea.job }}-network" \
-w ${{ github.workspace }} \
siteworxpro/migrate:v4.18.3 -database "postgres://postgres:postgres@${{ gitea.job }}-${{ gitea.run_id }}-postgres:5432/postgres?sslmode=disable" -path db/migrations up
- name: 💽 ⬇️ Rollback Migrations
run: |
docker run \
--name ${{ gitea.run_id }}-migrate \
--rm \
--volumes-from ${{ env.JOB_CONTAINER_NAME }} \
--network "${{ env.JOB_CONTAINER_NAME }}-${{ gitea.job }}-network" \
-w ${{ github.workspace }} \
siteworxpro/migrate:v4.18.3 -database "postgres://postgres:postgres@${{ gitea.job }}-${{ gitea.run_id }}-postgres:5432/postgres?sslmode=disable" -path db/migrations down --all
- name: 🧨 💥 Tear Down Support Containers
if: always()
run: |
docker stop ${{ gitea.job }}-${{ gitea.run_id }}-postgres
LibraryAudit:
name: 🛡️ 🔒 Library Audit
runs-on: ubuntu-latest
steps:
- name: 🛡️ 🔒 Add Siteworx CA Certificates
run: |
curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt
update-ca-certificates
- name: 📖 🔍 Checkout Repository Code
uses: actions/checkout@v2
with:
fetch-depth: 1
- name: 🔑 🔐 Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: 📖 ✨ Install Composer Libraries
run: |
docker run --rm \
-v composer-cache:/tmp/cache \
--volumes-from ${{ env.JOB_CONTAINER_NAME }} \
-w ${{ github.workspace }} \
siteworxpro/composer \
install --ignore-platform-reqs --no-interaction --prefer-dist --optimize-autoloader
- name: Run License Check
- name: Run Library Audit
run: |
docker run --rm \
--volumes-from ${{ env.JOB_CONTAINER_NAME }} \
-w ${{ github.workspace }} \
siteworxpro/composer \
audit
LicenseCheck:
name: 🛡️ 🔒 License Check
runs-on: ubuntu-latest
steps:
- name: 🛡️ 🔒 Add Siteworx CA Certificates
run: |
curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt
update-ca-certificates
- name: 📖 🔍 Checkout Repository Code
uses: actions/checkout@v2
with:
fetch-depth: 1
- name: 🔑 🔐 Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: 📖 ✨ Install Composer Libraries
run: |
docker run --rm \
-v composer-cache:/tmp/cache \
--volumes-from ${{ env.JOB_CONTAINER_NAME }} \
-w ${{ github.workspace }} \
siteworxpro/composer \
install --ignore-platform-reqs --no-interaction --prefer-dist --optimize-autoloader
- name: 🎟️ 🔬 Run License Check
run: |
docker run --rm \
--volumes-from ${{ env.JOB_CONTAINER_NAME }} \
@@ -44,12 +144,11 @@ jobs:
run tests:license
CodeLint:
name: Code Lint
name: 📝 ✨ Code Lint
runs-on: ubuntu-latest
steps:
- name: 🛡️ 🔒 Add Siteworx CA Certificates
run: |
apt update && apt install -yq ca-certificates curl
curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt
update-ca-certificates
@@ -64,9 +163,10 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Install Composer Libraries
- name: 📖 ✨ Install Composer Libraries
run: |
docker run --rm \
-v composer-cache:/tmp/cache \
--volumes-from ${{ env.JOB_CONTAINER_NAME }} \
-w ${{ github.workspace }} \
siteworxpro/composer \
@@ -81,12 +181,11 @@ jobs:
run tests:lint
CodeSniffer:
name: Code Sniffer
name: 🐙 🔍 Code Sniffer
runs-on: ubuntu-latest
steps:
- name: 🛡️ 🔒 Add Siteworx CA Certificates
run: |
apt update && apt install -yq ca-certificates curl
curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt
update-ca-certificates
@@ -101,9 +200,10 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Install Composer Libraries
- name: 📖 ✨ Install Composer Libraries
run: |
docker run --rm \
-v composer-cache:/tmp/cache \
--volumes-from ${{ env.JOB_CONTAINER_NAME }} \
-w ${{ github.workspace }} \
siteworxpro/composer \
@@ -118,12 +218,11 @@ jobs:
run tests:phpstan
UnitTests:
name: Unit Tests
name: 🧪 ✅ Unit Tests
runs-on: ubuntu-latest
steps:
- name: 🛡️ 🔒 Add Siteworx CA Certificates
run: |
apt update && apt install -yq ca-certificates curl
curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt
update-ca-certificates
@@ -138,18 +237,32 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Install Composer Libraries
- name: 📖 ✨ Install Composer Libraries
run: |
docker run --rm \
-v composer-cache:/tmp/cache \
--volumes-from ${{ env.JOB_CONTAINER_NAME }} \
-w ${{ github.workspace }} \
siteworxpro/composer \
install --ignore-platform-reqs --no-interaction --prefer-dist --optimize-autoloader
- name: Run Unit Tests
- name: 🧪 ✅ Run Unit Tests
uses: addnab/docker-run-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
image: siteworxpro/composer
options: --volumes-from ${{ env.JOB_CONTAINER_NAME }} -w ${{ gitea.workspace }}
run: |
docker run --rm \
--volumes-from ${{ env.JOB_CONTAINER_NAME }} \
-w ${{ github.workspace }} \
siteworxpro/composer \
run tests:unit
bin/pcov.sh
composer run tests:unit:coverage
# - name: 📦 Publish Build Artifacts
# env:
# NODE_TLS_REJECT_UNAUTHORIZED: 0
# uses: christopherhx/gitea-upload-artifact@v4
# with:
# options: --volumes-from ${{ env.JOB_CONTAINER_NAME }} -w ${{ gitea.workspace }}
# name: junit-coverage.xml
# path: tests/reports/junit.xml
# retention-days: 1

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
.idea/
.DS_Store
vendor/
.phpunit.cache/
tests/reports/

View File

@@ -1,5 +0,0 @@
include:
- local: .gitlab/ci/stages.yml
- local: .gitlab/ci/tests.yml
- local: .gitlab/ci/libraries.yml

View File

@@ -1,15 +0,0 @@
Install Composer Libraries:
stage: libraries
image: siteworxpro/composer:latest
rules:
- if: '$CI_COMMIT_TAG'
when: never
- if: '$CI_PIPELINE_SOURCE == "push"'
when: always
- when: never
script:
- composer install --ignore-platform-reqs
artifacts:
paths:
- vendor/
expire_in: 1 hour

View File

@@ -1,3 +0,0 @@
stages:
- libraries
- tests

View File

@@ -1,65 +0,0 @@
Unit Tests:
stage: tests
needs:
- Install Composer Libraries
rules:
- if: '$CI_COMMIT_TAG'
when: never
- if: '$CI_PIPELINE_SOURCE == "push"'
when: always
- when: never
image: siteworxpro/composer
before_script: |
bin/pcov.sh
script: |
echo "Running unit tests..."
composer run tests:unit:coverage
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
artifacts:
expire_in: 1 day
reports:
junit: tests/reports/junit.xml
paths:
- tests/reports/
Run License Check:
stage: tests
needs:
- Install Composer Libraries
rules:
- if: '$CI_COMMIT_TAG'
when: never
- if: '$CI_PIPELINE_SOURCE == "push"'
when: on_success
- when: never
image: siteworxpro/composer
script:
- composer run tests:license
Run Code Lint:
stage: tests
needs:
- Install Composer Libraries
rules:
- if: '$CI_COMMIT_TAG'
when: never
- if: '$CI_PIPELINE_SOURCE == "push"'
when: on_success
- when: never
image: siteworxpro/composer
script:
- composer run tests:lint
Run Code Sniffer:
stage: tests
needs:
- Install Composer Libraries
rules:
- if: '$CI_COMMIT_TAG'
when: never
- if: '$CI_PIPELINE_SOURCE == "push"'
when: on_success
- when: never
image: siteworxpro/composer
script:
- composer run tests:phpstan

View File

@@ -21,4 +21,4 @@ http:
logs:
encoding: json
level: ${LOG_LEVEL:-info}
mode: production
mode: ${LOG_MODE:-production}

View File

@@ -0,0 +1,10 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name=" 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" />
</settings>
</deployment>
<method v="2" />
</configuration>
</component>

8
.run/All.run.xml Normal file
View File

@@ -0,0 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="All" type="ComposerRunConfigurationType" factoryName="Composer Script">
<option name="commandLineParameters" value="" />
<option name="pathToComposerJson" value="$PROJECT_DIR$/composer.json" />
<option name="script" value="tests:all" />
<method v="2" />
</configuration>
</component>

8
.run/Lint_fix.run.xml Normal file
View File

@@ -0,0 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Lint:fix" type="ComposerRunConfigurationType" factoryName="Composer Script">
<option name="commandLineParameters" value="" />
<option name="pathToComposerJson" value="$PROJECT_DIR$/composer.json" />
<option name="script" value="tests:lint:fix" />
<method v="2" />
</configuration>
</component>

11
.run/Main.run.xml Normal file
View File

@@ -0,0 +1,11 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Main" type="PHPUnitRunConfigurationType" factoryName="PHPUnit">
<CommandLine>
<PhpTestInterpreterSettings>
<option name="interpreterName" value="composer-runtime" />
</PhpTestInterpreterSettings>
</CommandLine>
<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.1 AS roadrunner
FROM ghcr.io/roadrunner-server/roadrunner:2025.1.4 AS roadrunner
# Use the official Composer image as the base for the library stage
FROM siteworxpro/composer AS library
@@ -12,14 +12,25 @@ 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.6-alpine AS php
FROM siteworxpro/php:8.5.0-cli-alpine AS php
ARG KAFKA_ENABLED=0
# 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 \
&& apk add libpq-dev linux-headers --no-cache \
&& docker-php-ext-install pdo_pgsql sockets \
&& docker-php-ext-install pdo_pgsql sockets pcntl \
&& rm -rf /var/cache/apk/*
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 ; \
else \
echo "Kafka support disabled" ; \
exit 0 ; \
fi
# Set the working directory to /app
WORKDIR /app
@@ -33,6 +44,7 @@ COPY --from=library /app/vendor /app/vendor
ADD src src/
ADD server.php .
ADD .rr.yaml .
ADD config.php .
EXPOSE 9501

View File

@@ -1,13 +1,12 @@
# Template
```shell
export PHP_IDE_CONFIG=serverName=localhost
```
![pipeline status](https://gitea.siteworxpro.com/siteworxpro/Php-Template/actions/workflows/tests.yml/badge.svg?branch=master&style=flat-square)
## Dev Environment
```shell
docker run --rm -v $(PWD):/app siteworxpro/composer run tests:all
```
### Prerequisites
- Docker
- Docker Compose
### migrations
@@ -16,7 +15,6 @@ create a new migration
docker run --rm -v $(PWD):/app siteworxpro/migrate:v4.18.3 create -ext sql -dir /app/db/migrations -seq create_users_table
```
```text
postgres://siteworxpro:password@localhost:5432/siteworxpro?sslmode=disable
```
@@ -24,3 +22,52 @@ postgres://siteworxpro:password@localhost:5432/siteworxpro?sslmode=disable
```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
```
### Starting the Runtime
```shell
docker-compose up -d
```
### Start the server
```shell
docker exec -it template-dev-runtime-1 rr serve
```
You can access the api at `http://localhost:9501/`
### 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
```
### Install the dependencies
```shell
docker run --rm -v $(PWD):/app siteworxpro/composer install --ignore-platform-reqs
```
### Running all tests
```shell
docker run --rm -v $(PWD):/app siteworxpro/composer run tests:all
```
## License
```text
Copyright (c)2025 Siteworx Professionals, LLC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation
files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom
the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```

4
bin/migrate.sh Executable file
View File

@@ -0,0 +1,4 @@
eval #!/bin/sh
set -e
migrate -path /app/db/migrations -database "postgres://$DB_USERNAME:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_DATABASE?sslmode=disable" up

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

11
cli.php Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/local/bin/php
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Siteworxpro\App\Cli\App;
$cliApp = new App();
exit($cliApp->run());

View File

@@ -8,22 +8,32 @@
}
},
"require": {
"php": "^8.4",
"league/route": "^6.2",
"illuminate/database": "^12.10",
"spiral/roadrunner-http": "^3.5",
"nyholm/psr7": "^1.8",
"php": "^8.5",
"league/route": "^6.2.0",
"illuminate/database": "^v12.34.0",
"spiral/roadrunner-http": "^v3.6.0",
"nyholm/psr7": "^1.8.2",
"illuminate/support": "^v12.10.2",
"roadrunner-php/app-logger": "^1.2",
"siteworxpro/config": "^1.1",
"predis/predis": "^3.0"
"roadrunner-php/app-logger": "^1.2.0",
"siteworxpro/config": "^1.1.1",
"predis/predis": "^v3.2.0",
"siteworxpro/http-status": "0.0.2",
"lcobucci/jwt": "^5.6",
"adhocore/cli": "^1.9",
"robinvdvleuten/ulid": "^5.0",
"monolog/monolog": "^3.9",
"react/promise": "^3",
"react/async": "^4",
"guzzlehttp/guzzle": "^7.10",
"zircote/swagger-php": "^5.7"
},
"require-dev": {
"phpunit/phpunit": "^12.1",
"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"
"phpstan/phpstan": "^2.1.31",
"kwn/php-rdkafka-stubs": "^2.2"
},
"scripts": {
"tests:all": [
@@ -53,16 +63,10 @@
"phpstan analyse --level 4 ./src/ -c phpstan.neon"
]
},
"repositories": {
"git.siteworxpro.com/24": {
"repositories": [
{
"type": "composer",
"url": "https://git.siteworxpro.com/api/v4/group/24/-/packages/composer/packages.json",
"options": {
"ssl": {
"verify_peer": false,
"allow_self_signed": true
}
}
}
"url": "https://gitea.siteworxpro.com/api/packages/php-packages/composer"
}
]
}

2542
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,11 @@ use Siteworxpro\App\Helpers\Env;
return [
'app' => [
'log_level' => Env::get('LOG_LEVEL', 'debug'),
'dev_mode' => Env::get('DEV_MODE', false, 'bool'),
],
/**
* The server configuration.
*/
@@ -21,8 +26,14 @@ return [
'database' => Env::get('DB_DATABASE', 'siteworxpro'),
'username' => Env::get('DB_USERNAME', 'siteworxpro'),
'password' => Env::get('DB_PASSWORD', 'password'),
'port' => Env::get('DB_PORT', 5432, 'int'),
'charset' => Env::get('DB_CHARSET', 'utf8'),
'collation' => Env::get('DB_COLLATION', 'utf8_unicode_ci'),
'prefix' => Env::get('DB_PREFIX', ''),
'options' => [
// Add any additional PDO options here
],
],
'cors' => [
'allowed_origins' => Env::get('CORS_ALLOWED_ORIGINS', 'localhost:3000'),
@@ -34,5 +45,45 @@ return [
'host' => Env::get('REDIS_HOST', 'localhost'),
'port' => Env::get('REDIS_PORT', 6379, 'int'),
'database' => Env::get('REDIS_DATABASE', 0, 'int'),
'password' => Env::get('REDIS_PASSWORD'),
],
'jwt' => [
'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', false, 'bool'),
],
'queue' => [
'broker' => Env::get('QUEUE_BROKER', 'redis'),
'broker_config' => [
'redis' => [
'consumerGroup' => Env::get('QUEUE_REDIS_CONSUMER_GROUP', ''),
],
'kafka' => [
'brokers' => Env::get('QUEUE_KAFKA_BROKERS', 'kafka:9092'),
'consumerGroup' => Env::get('QUEUE_KAFKA_CONSUMER_GROUP', 'default_group'),
],
'rabbitmq' => [
'host' => Env::get('QUEUE_RABBITMQ_HOST', 'localhost'),
'port' => Env::get('QUEUE_RABBITMQ_PORT', 5672, 'int'),
'username' => Env::get('QUEUE_RABBITMQ_USERNAME', 'guest'),
'password' => Env::get('QUEUE_RABBITMQ_PASSWORD', 'guest'),
'vhost' => Env::get('QUEUE_RABBITMQ_VHOST', '/'),
],
'sqs' => [
'key' => Env::get('QUEUE_SQS_KEY', ''),
'secret' => Env::get('QUEUE_SQS_SECRET', ''),
'region' => Env::get('QUEUE_SQS_REGION', 'us-east-1'),
'version' => Env::get('QUEUE_SQS_VERSION', 'latest'),
'queue_url' => Env::get('QUEUE_SQS_QUEUE_URL', ''),
]
]
]
];

View File

@@ -4,6 +4,31 @@ volumes:
services:
traefik:
image: traefik:latest
container_name: traefik
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
restart: always
command:
- "--providers.docker=true"
- "--ping"
- "--providers.docker.exposedByDefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web-secure.address=:443"
- "--accesslog=true"
- "--entrypoints.web.http.redirections.entryPoint.to=web-secure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--entrypoints.web.http.redirections.entrypoint.permanent=true"
composer-runtime:
volumes:
- .:/app
@@ -12,30 +37,130 @@ services:
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
image: siteworxpro/migrate:v4.18.3
working_dir: /app
# entrypoint: "/bin/sh -c 'while true; do sleep 30; done;'"
entrypoint: /bin/sh -c '/app/bin/migrate.sh'
depends_on:
postgres:
condition: service_healthy
environment:
DB_USERNAME: ${DB_USERNAME:-siteworxpro}
DB_PASSWORD: ${DB_PASSWORD:-password}
DB_DATABASE: ${DB_DATABASE:-siteworxpro}
DB_HOST: ${DB_HOST-postgres}
DB_PORT: ${DB_PORT-5432}
dev-runtime:
ports:
- "9501:9501"
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.entrypoints=web-secure"
- "traefik.http.routers.api.rule=Host(`localhost`) || Host(`127.0.0.1`)"
- "traefik.http.routers.api.tls=true"
- "traefik.http.routers.api.service=api"
- "traefik.http.services.api.loadbalancer.healthcheck.path=/healthz"
- "traefik.http.services.api.loadbalancer.healthcheck.interval=5s"
- "traefik.http.services.api.loadbalancer.healthcheck.timeout=60s"
volumes:
- .:/app
build:
args:
KAFKA_ENABLED: "1"
context: .
dockerfile: Dockerfile
entrypoint: "/bin/sh -c 'while true; do sleep 30; done;'"
depends_on:
migration-container:
condition: service_completed_successfully
traefik:
condition: service_healthy
redis:
condition: service_healthy
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
DEBUG: 1
REDIS_HOST: redis
DB_HOST: postgres
DEV_MODE: 1
## Kafka and Zookeeper for local development
kafka-ui:
image: kafbat/kafka-ui:latest # Or kafbat/kafka-ui:latest for newer Kafka
container_name: kafka-ui
ports:
- "8080:8080" # Expose the UI port
environment:
KAFKA_CLUSTERS_0_NAME: local-kafka-cluster
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092
depends_on:
kafka:
condition: service_started
zookeeper:
condition: service_started
zookeeper:
image: ubuntu/zookeeper:latest
environment:
ALLOW_ANONYMOUS_LOGIN: "yes"
ports:
- "2181:2181"
kafka:
image: ubuntu/kafka:latest
environment:
KAFKA_BROKER_ID: 1
KAFKA_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
ALLOW_PLAINTEXT_LISTENER: "yes"
ports:
- "9092:9092"
depends_on:
zookeeper:
condition: service_started
redis:
image: redis:latest
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "6379:6379"
volumes:
- redisdata:/data
postgres:
image: postgres:latest
image: postgres:18
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-siteworxpro}"]
interval: 10s
timeout: 5s
retries: 5
environment:
POSTGRES_USER: ${DB_USERNAME:-siteworxpro}
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
@@ -43,4 +168,4 @@ services:
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- pgdata:/var/lib/postgresql

8
migrations.Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM siteworxpro/migrate:v4.18.3
ADD db/migrations /app/db/migrations
ADD bin/migrate.sh /app/bin/migrate.sh
WORKDIR /app
ENTRYPOINT ["/app/bin/migrate.sh"]

View File

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

View File

@@ -4,16 +4,22 @@ declare(strict_types=1);
namespace Siteworxpro\App;
use Illuminate\Container\Container;
use Illuminate\Support\Facades\Facade;
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\Facades\Config;
use Siteworxpro\App\Facades\Logger;
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 Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\Worker;
@@ -26,7 +32,7 @@ use Spiral\RoadRunner\Worker;
*
* @package Siteworxpro\App
*/
class Server
class Api
{
/**
* @var Router The router instance for handling routes.
@@ -38,69 +44,13 @@ class Server
*/
protected PSR7Worker $worker;
/**
* Server constructor.
*
* Initializes the server by booting the PSR-7 worker and router.
* @throws \ReflectionException
*/
public function __construct()
{
$this->boot();
}
/**
* Bootstraps the server by initializing the PSR-7 worker and router.
*
* This method sets up the PSR-7 worker and router instances, and registers
* the routes for the server. It should be called in the constructor of
* subclasses to ensure proper initialization.
*
* @return void
*/
private function boot(): void
{
$container = new Container();
Facade::setFacadeApplication($container);
$this->worker = new PSR7Worker(
Worker::create(),
new Psr17Factory(),
new Psr17Factory(),
new Psr17Factory()
);
$this->router = new Router();
Kernel::boot();
$this->registerRoutes();
$this->bootModelCapsule();
}
/**
* Bootstraps the model capsule for database connections.
*
* This method sets up the database connection using the Eloquent ORM.
* It retrieves the database configuration from the Config facade and
* initializes the Eloquent capsule manager.
*
* @return void
*/
public function bootModelCapsule(): void
{
$capsule = new \Illuminate\Database\Capsule\Manager();
$capsule->addConnection([
'driver' => Config::get('db.driver'),
'host' => Config::get('db.host'),
'database' => Config::get('db.database'),
'username' => Config::get('db.username'),
'password' => Config::get('db.password'),
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',
]);
$capsule->setAsGlobal();
$capsule->bootEloquent();
}
/**
@@ -111,13 +61,28 @@ class Server
*
* @return void
*/
protected function registerRoutes(): void
public function registerRoutes(): void
{
$this->router->get('/', function () {
return JsonResponseFactory::createJsonResponse(['status_code' => 200, 'message' => 'Server is running']);
$this->worker = new PSR7Worker(
Worker::create(),
new Psr17Factory(),
new Psr17Factory(),
new Psr17Factory()
);
$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());
}
/**
@@ -148,27 +113,21 @@ class Server
$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'],
404
)
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, 500));
$this->worker->respond(
JsonResponseFactory::createJsonResponse(new ServerErrorResponse($e))
);
}
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Brokers;
abstract class Broker implements BrokerInterface
{
public const array BROKER_TYPES = [
'redis' => Redis::class,
'rabbitmq' => RabbitMQ::class,
'kafka' => Kafka::class,
'sqs' => Sqs::class,
];
public function __construct(protected $config = [])
{
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Brokers;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Async\Messages\Message;
interface BrokerInterface
{
public function publish(Queue $queue, Message $message, ?int $delay = null): void;
public function consume(Queue $queue): Message | null;
public function acknowledge(Queue $queue, Message $message): void;
public function reject(Queue $queue, Message $message, bool $requeue = false): void;
public function purge(Queue $queue): void;
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Brokers;
use RdKafka\Conf;
use RdKafka\Exception;
use RdKafka\KafkaConsumer;
use RdKafka\Producer;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Async\Messages\Message;
class Kafka extends Broker
{
private Producer $producer;
private KafkaConsumer $consumer;
public function __construct($config = [])
{
parent::__construct($config);
$conf = new Conf();
$conf->set('bootstrap.servers', $config['brokers'] ?? 'localhost:9092');
$this->producer = new Producer($conf);
$this->producer->addBrokers($config['brokers'] ?? 'localhost:9092');
$conf->set('group.id', $config['consumerGroup'] ?? 'default');
$conf->set('auto.offset.reset', 'earliest');
$this->consumer = new KafkaConsumer($conf);
}
public function __destruct()
{
$this->producer->flush(1000);
}
/**
* @throws \Exception
*/
public function publish(Queue $queue, Message $message, ?int $delay = null): void
{
$topic = $this->producer->newTopic($queue->queueName());
$topic->produce(RD_KAFKA_PARTITION_UA, 0, $message->serialize(), $message->getId());
$this->producer->flush(1000);
}
/**
* @throws Exception
*/
public function consume(Queue $queue): Message|null
{
$this->consumer->subscribe([$queue->queueName()]);
$kafkaMessage = $this->consumer->consume(1000);
if ($kafkaMessage->err === RD_KAFKA_RESP_ERR__TIMED_OUT) {
return null;
}
if ($kafkaMessage->err === RD_KAFKA_RESP_ERR_UNKNOWN_TOPIC_OR_PART) {
throw new \RuntimeException(
"Topic '{$queue->queueName()}' or partition does not exist. Kafka does not auto-create topics" .
" unless configured to do so."
);
}
/** @var string | null $messageData */
$messageData = $kafkaMessage->payload;
if ($messageData !== null) {
/** @var Message $message */
$message = unserialize($messageData, ['allowed_classes' => true]);
$message->setId((string)$kafkaMessage->offset);
return $message;
}
return null;
}
public function acknowledge(Queue $queue, Message $message): void
{
}
public function reject(Queue $queue, Message $message, bool $requeue = false): void
{
}
public function purge(Queue $queue): void
{
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Brokers;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Async\Messages\Message;
class RabbitMQ extends Broker
{
public function publish(Queue $queue, Message $message, ?int $delay = null): void
{
// TODO: Implement publish() method.
}
public function consume(Queue $queue): Message | null
{
return null;
}
public function acknowledge(Queue $queue, Message $message): void
{
// TODO: Implement acknowledge() method.
}
public function reject(Queue $queue, Message $message, bool $requeue = false): void
{
// TODO: Implement reject() method.
}
public function purge(Queue $queue): void
{
// TODO: Implement purge() method.
}
}

190
src/Async/Brokers/Redis.php Normal file
View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Brokers;
use Predis\Client;
use Predis\Command\RawCommand;
use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Helpers\Ulid;
class Redis extends Broker
{
private Client $client;
private string $consumerId;
private string $consumerGroup;
private const string CONSUMER_ID_PREFIX = 'consumer-group:';
private const string QUEUE_PREFIX = 'queue:';
private array $queueNames = [];
public function __construct($config = [])
{
parent::__construct($config);
$this->client = \Siteworxpro\App\Services\Facades\Redis::getFacadeRoot();
$this->consumerId = php_uname('n') . ':' . getmypid();
$this->consumerGroup = $config['consumerGroup'] ?? 'default';
}
private function ensureQueue(string $queueName): void
{
if (in_array($queueName, $this->queueNames, true)) {
return;
}
try {
$this->client->executeCommand(
new RawCommand(
'XGROUP',
[
'CREATE',
self::QUEUE_PREFIX . $queueName,
self::CONSUMER_ID_PREFIX . $this->consumerGroup,
'$',
'MKSTREAM'
]
)
);
} catch (\Exception) {
// If the group already exists, we catch the exception and ignore it
// This is because Redis will throw an error if the group already exists
// We can safely ignore this error as it means the group is already set up
}
$this->client->executeCommand(
new RawCommand(
'XGROUP',
[
'CREATECONSUMER',
self::QUEUE_PREFIX . $queueName,
self::CONSUMER_ID_PREFIX . $this->consumerGroup,
$this->consumerId
]
)
);
$this->queueNames[] = $queueName;
}
public function __destruct()
{
foreach ($this->queueNames as $queueName) {
try {
$this->client->executeCommand(
new RawCommand(
'XGROUP',
[
'DELCONSUMER',
self::QUEUE_PREFIX . $queueName,
self::CONSUMER_ID_PREFIX . $this->consumerGroup,
$this->consumerId
]
)
);
} catch (\Exception) {
// Ignore exceptions during cleanup
}
}
}
/**
* @throws \Exception
*/
public function publish(Queue $queue, Message $message, ?int $delay = null): void
{
$command = '%s * data %s';
$command = sprintf(
$command,
self::QUEUE_PREFIX .
$queue->queueName(),
base64_encode($message->serialize())
);
/** @var string $result */
$result = $this
->client
->executeCommand(
new RawCommand('XADD', explode(' ', $command)),
);
$message->setId($result);
}
public function consume(Queue $queue): Message|null
{
$this->ensureQueue($queue->queueName());
$command = 'GROUP %s %s COUNT 1 STREAMS %s >';
$command = sprintf(
$command,
self::CONSUMER_ID_PREFIX . $this->consumerGroup,
$this->consumerId,
self::QUEUE_PREFIX . $queue->queueName(),
);
/** @var array | null $response */
$response = $this
->client
->executeCommand(
new RawCommand(
'XREADGROUP',
explode(' ', $command)
)
);
if ($response === null || !isset($response[0][1][0][1][1])) {
return null;
}
$messageData = base64_decode($response[0][1][0][1][1]);
$messageId = $response[0][1][0][0];
if ($messageData === 'NOOP') {
// If the message is a NOOP, we return null to indicate no actual message
return null;
}
$value = unserialize($messageData, ['allowed_classes' => true]);
if (!$value instanceof Message) {
return null;
}
$value->setId($messageId);
return $value;
}
public function acknowledge(Queue $queue, Message $message): void
{
$response = $this
->client
->executeCommand(
new RawCommand(
'XACK',
[
self::QUEUE_PREFIX . $queue->queueName(),
self::CONSUMER_ID_PREFIX . $this->consumerGroup,
$message->getId()
]
)
);
}
public function reject(Queue $queue, Message $message, bool $requeue = false): void
{
// TODO: Implement reject() method.
}
public function purge(Queue $queue): void
{
// TODO: Implement purge() method.
}
}

36
src/Async/Brokers/Sqs.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Brokers;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Async\Messages\Message;
class Sqs extends Broker
{
public function publish(Queue $queue, Message $message, ?int $delay = null): void
{
// TODO: Implement publish() method.
}
public function consume(Queue $queue): Message | null
{
return null;
}
public function acknowledge(Queue $queue, Message $message): void
{
// TODO: Implement acknowledge() method.
}
public function reject(Queue $queue, Message $message, bool $requeue = false): void
{
// TODO: Implement reject() method.
}
public function purge(Queue $queue): void
{
// TODO: Implement purge() method.
}
}

173
src/Async/Consumer.php Normal file
View File

@@ -0,0 +1,173 @@
<?php
declare(ticks=1);
namespace Siteworxpro\App\Async;
use Siteworxpro\App\Attributes\Async\HandlesMessage;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Services\Facades\Broker;
use Siteworxpro\App\Services\Facades\Logger;
/**
* Long-running process that listens to queues, pops messages, and dispatches them to handlers.
*/
class Consumer
{
private static bool $shutDown = false;
/** @var array<string,string> */
private const array QUEUES = [
'default' => Queues\DefaultQueue::class,
];
/** @var Queue[] */
private array $queues = [];
/** @var array<string, string[]> message FQCN => handler FQCNs */
private array $handlers = [];
private const string HANDLER_NAMESPACE = 'Siteworxpro\\App\\Async\\Handlers\\';
/**
* @param string[] $queues Optional list of queue names (keys from self::QUEUES)
*/
public function __construct(array $queues = [])
{
$queueClasses = $queues === []
? array_values(self::QUEUES)
: array_map(
static function (string $name): string {
if (!isset(self::QUEUES[$name])) {
throw new \InvalidArgumentException("Queue '$name' is not defined.");
}
return self::QUEUES[$name];
},
$queues
);
foreach ($queueClasses as $class) {
$this->queues[] = new $class();
}
$this->registerHandlers();
}
/**
* Discover handler classes under `Handlers` and register them via HandlesMessage attributes.
*/
private function registerHandlers(): void
{
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator(__DIR__ . '/Handlers/')
);
/** @var \SplFileInfo $file */
foreach ($it as $file) {
if (!$file->isFile() || $file->getExtension() !== 'php') {
continue;
}
$relative = str_replace(__DIR__ . '/Handlers/', '', $file->getPathname());
$class = self::HANDLER_NAMESPACE . str_replace('/', '\\', substr($relative, 0, -4));
if (!class_exists($class)) {
continue;
}
$ref = new \ReflectionClass($class);
foreach ($ref->getAttributes(HandlesMessage::class) as $attr) {
$messageClass = $attr->newInstance()->getMessageClass();
$this->handlers[$messageClass][] = $class;
}
}
}
/**
* Signal handler used to initiate graceful or immediate shutdown.
*/
public static function handleSignal(int $signal): void
{
switch ($signal) {
case SIGINT:
case SIGTERM:
case SIGHUP:
self::$shutDown = true;
return;
case SIGKILL:
exit(9);
}
}
private function shouldShutDown(): bool
{
return self::$shutDown;
}
/**
* Start the consumer main loop.
*/
public function start(): void
{
if (!\function_exists('pcntl_signal')) {
throw new \RuntimeException('The pcntl extension is required to handle signals.');
}
Logger::info('Starting queue consumer...');
Logger::info('Using Broker: ' . Broker::getFacadeRoot()::class);
foreach ([SIGINT, SIGTERM, SIGHUP] as $sig) {
\pcntl_signal($sig, [self::class, 'handleSignal']);
}
while (true) {
if ($this->shouldShutDown()) {
Logger::info('Shutting down queue consumer...');
break;
}
/** @var Queue $queue */
foreach ($this->queues as $queue) {
Logger::info('Listening to queue: ' . $queue->queueName());
$message = $queue->pop();
if (!$message) {
continue;
}
Logger::info('Processing message of type: ' . get_class($message));
foreach ($this->getHandlersForMessage($message) as $handler) {
$handler($message);
}
// Continue polling from the top of the loop after processing a message.
continue 2;
}
// Avoid busy-looping when no messages are available.
sleep(1);
}
}
/**
* @return callable[] Handler instances invokable with the message
*/
private function getHandlersForMessage(Message $message): array
{
$messageClass = get_class($message);
if (!isset($this->handlers[$messageClass])) {
throw new \RuntimeException("No handler found for message class: $messageClass");
}
$callables = [];
foreach ($this->handlers[$messageClass] as $handlerClass) {
if (class_exists($handlerClass)) {
$callables[] = new $handlerClass();
}
}
return $callables;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Handlers;
use Siteworxpro\App\Async\Messages\Message;
interface HandlerInterface
{
public function __invoke(Message $message): void;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Handlers;
use Siteworxpro\App\Attributes\Async\HandlesMessage;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Services\Facades\Logger;
#[HandlesMessage(SayHelloMessage::class)]
class SayHelloHandler implements HandlerInterface
{
public function __invoke(Message | SayHelloMessage $message): void
{
$name = $message->getPayload()['name'] ?? 'Guest';
Logger::info(sprintf("Hello, %s!", $name));
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Messages;
use Siteworxpro\App\Async\Queues\DefaultQueue;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Helpers\Ulid;
abstract class Message implements \Serializable
{
protected string $id = '';
protected string $uniqueId;
protected array $payload;
protected int $timestamp;
protected string $queue = '';
protected const string DEFAULT_QUEUE = DefaultQueue::class;
abstract public static function dispatch(...$args): void;
abstract public static function dispatchLater(int $delay, ...$args): void;
public function __construct()
{
$this->uniqueId = Ulid::generate();
$this->timestamp = time();
}
protected function getQueue(): Queue
{
if ($this->queue === '') {
$this->queue = static::DEFAULT_QUEUE;
}
return new $this->queue();
}
public function getId(): string
{
return $this->id;
}
/**
* @param string $id
*/
public function setId(string $id): void
{
$this->id = $id;
}
public function getPayload(): array
{
return $this->payload;
}
public function getTimestamp(): int
{
return $this->timestamp;
}
public function __serialize(): array
{
return [
'id' => $this->id,
'payload' => $this->payload,
'timestamp' => $this->timestamp,
'queue' => $this->queue,
];
}
public function __unserialize(array $data): void
{
$this->id = $data['id'];
$this->payload = $data['payload'];
$this->timestamp = $data['timestamp'];
$this->queue = $data['queue'];
}
public function serialize(): string
{
return serialize($this);
}
public function unserialize(string $data): Message
{
$unserializedData = unserialize($data, ['allowed_classes' => [Message::class]]);
$this->id = $unserializedData['id'];
$this->uniqueId = $unserializedData['uniqueId'];
$this->payload = $unserializedData['payload'];
$this->timestamp = $unserializedData['timestamp'];
$this->queue = $unserializedData['queue'];
return $this;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Messages;
use Siteworxpro\App\Services\Facades\Broker;
class SayHelloMessage extends Message
{
public static function dispatch(...$args): void
{
$name = $args[0] ?? 'World';
$message = new self($name);
Broker::publish(
$message->getQueue(),
$message
);
}
public static function dispatchLater(int $delay, ...$args): void
{
$name = $args[0] ?? 'World';
$message = new self($name);
Broker::publishLater(
$message->getQueue(),
$message,
$delay
);
}
private function __construct(
private readonly string $name
) {
parent::__construct();
$this->payload = [
'name' => $this->name,
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Queues;
readonly class DefaultQueue extends Queue
{
public function queueName(): string
{
return 'default';
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Queues;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Services\Facades\Broker;
readonly abstract class Queue
{
abstract public function queueName(): string;
public function push(Message $message): void
{
Broker::publish($this, $message);
}
public function later(int $delay, Message $message): void
{
Broker::publish($this, $message, $delay);
}
public function pop(): Message | null
{
return Broker::consume($this);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\Async;
use Attribute;
/**
* Attribute to mark a class as a handler for a specific message class in an async workflow.
*
* Repeatable: attach multiple times to handle multiple message classes.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
readonly class HandlesMessage
{
/**
* Create a new HandlesMessage attribute.
*
* @param class-string $messageClass Fully-qualified class name of the message handled.
*/
public function __construct(
public string $messageClass,
) {
}
/**
* Get the fully-qualified message class this handler processes.
*
* @return class-string
*/
public function getMessageClass(): string
{
return $this->messageClass;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\Events;
use Attribute;
/**
* Attribute to mark a class as an event listener for a specific event class.
*
* Apply this attribute to classes that subscribe to domain or application events.
* Repeatable: can be attached multiple times to the same class to listen for multiple events.
*
* Targets: class only.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
readonly class ListensFor
{
/**
* Initialize the ListensFor attribute.
*
* @param class-string $eventClass Fully-qualified class name of the event to listen for.
*/
public function __construct(public string $eventClass)
{
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\Guards;
use Attribute;
use Siteworxpro\App\Services\Facades\Config;
/**
* Attribute to guard classes or methods with JWT claim requirements.
*
* Apply this attribute to a class or method to declare the expected JWT issuer and/or audience.
* If either the issuer or audience is an empty string, the value will be resolved from configuration:
* - `jwt.issuer`
* - `jwt.audience`
*
* Targets: class or method.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
readonly class Jwt
{
/**
* Initialize the Jwt attribute with optional overrides for expected JWT claims.
*
* @param string $issuer Optional expected JWT issuer (`iss`). Empty string uses `Config::get('jwt.issuer')`.
* @param string $audience Optional expected JWT audience (`aud`). Empty string uses `Config::get('jwt.audience')`.
*/
public function __construct(
private string $issuer = '',
private string $audience = '',
) {
}
/**
* Get the expected audience for validation.
*
* Returns the constructor-provided audience when non-empty; otherwise falls back to `jwt.audience` config.
*
* @return string The audience value to enforce.
*/
public function getAudience(): string
{
if ($this->audience === '') {
return Config::get('jwt.audience') ?? '';
}
return $this->audience;
}
/**
* Get the expected issuer for validation.
*
* Returns the constructor-provided issuer when non-empty; otherwise falls back to `jwt.issuer` config.
*
* @return string The issuer value to enforce.
*/
public function getIssuer(): string
{
if ($this->issuer === '') {
return Config::get('jwt.issuer') ?? '';
}
return $this->issuer;
}
}

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

46
src/Cli/App.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli;
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;
class App
{
private Application $app;
/**
* @throws \ReflectionException
*/
public function __construct()
{
Kernel::boot();
$this->app = new Application('Php-Template', Version::VERSION);
$this->app->add(new DemoCommand());
$this->app->add(new Start());
$this->app->add(new TestJob());
}
public function run(): int
{
$this->app->logo(
<<<EOF
▄▄▄▄▄ ▄ ▄ ▄▄▄▄▄ ▄▄▄▄▄▄▄ ▀▀█ ▄
█ ▀█ █ █ █ ▀█ █ ▄▄▄ ▄▄▄▄▄ ▄▄▄▄ █ ▄▄▄ ▄▄█▄▄ ▄▄▄
█▄▄▄█▀ █▄▄▄▄█ █▄▄▄█▀ █ █▀ █ █ █ █ █▀ ▀█ █ ▀ █ █ █▀ █
█ █ █ █ ▀▀▀ █ █▀▀▀▀ █ █ █ █ █ █ ▄▀▀▀█ █ █▀▀▀▀
█ █ █ █ █ ▀█▄▄▀ █ █ █ ██▄█▀ ▀▄▄ ▀▄▄▀█ ▀▄▄ ▀█▄▄▀
EOF
);
return $this->app->handle($_SERVER['argv']);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands;
interface CommandInterface
{
/**
* Execute the command.
*
* @return int
*/
public function execute(): int;
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands;
use Ahc\Cli\Input\Command;
class DemoCommand extends Command implements CommandInterface
{
public function __construct()
{
parent::__construct('api:demo', 'A demo command to showcase the CLI functionality.');
$this->argument('[name]', 'Your name')
->option('-g, --greet', 'Include a greeting message');
}
public function execute(): int
{
$pb = $this->progress(100);
for ($i = 0; $i < 100; $i += 10) {
usleep(100000); // Simulate work
$pb->advance(10);
}
$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");
}
return 0;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\Queue;
use Ahc\Cli\Input\Command;
use Siteworxpro\App\Async\Consumer;
use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Cli\Commands\CommandInterface;
class Start extends Command implements CommandInterface
{
public function __construct()
{
parent::__construct('queue:start', 'Start the queue consumer to process messages.');
$this->argument('[queues]', 'The name of the queue to consume from. ex. "first_queue,second_queue"');
}
public function execute(): int
{
$queues = [];
if ($this->values()['queues'] !== null) {
$queues = explode(',', $this->values()['queues']);
}
$consumer = new Consumer($queues);
$consumer->start();
return 0;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\Queue;
use Ahc\Cli\Input\Command;
use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Cli\Commands\CommandInterface;
/**
* Class TestJob
*
* A CLI command to schedule a demo job that dispatches a SayHelloMessage.
*/
class TestJob extends Command implements CommandInterface
{
public function __construct()
{
parent::__construct('queue:demo', 'Schedule a demo job.');
}
/**
* Execute the command to dispatch a SayHelloMessage.
*
* @return int Exit code
*/
public function execute(): int
{
SayHelloMessage::dispatch('World from TestJob Command!');
return 0;
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
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
*
* An abstract base controller providing default implementations for HTTP methods.
*
* @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
{
/**
* @param ServerRequest $request
* @return ResponseInterface
* @throws NotFoundException
*/
public function get(ServerRequest $request): ResponseInterface
{
throw new NotFoundException("not found");
}
/**
* @throws NotFoundException
*/
public function post(ServerRequest $request): ResponseInterface
{
throw new NotFoundException("not found");
}
/**
* @throws NotFoundException
*/
public function put(ServerRequest $request): ResponseInterface
{
throw new NotFoundException("not found");
}
/**
* @throws NotFoundException
*/
public function delete(ServerRequest $request): ResponseInterface
{
throw new NotFoundException("not found");
}
/**
* @throws NotFoundException
*/
public function patch(ServerRequest $request): ResponseInterface
{
throw new NotFoundException("not found");
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
/**
* Interface ControllerInterface
*
* Defines the contract for handling HTTP requests in a controller.
*/
interface ControllerInterface
{
/**
* Handle the request and return a response.
*
* @param ServerRequest $request The request data.
* @return ResponseInterface The response data.
*/
public function get(ServerRequest $request): ResponseInterface;
/**
* Handle the request and return a response.
*
* @param ServerRequest $request The request data.
* @return ResponseInterface The response data.
*/
public function post(ServerRequest $request): ResponseInterface;
/**
* Handle the request and return a response.
*
* @param ServerRequest $request The request data.
* @return ResponseInterface The response data.
*/
public function put(ServerRequest $request): ResponseInterface;
/**
* Handle the request and return a response.
*
* @param ServerRequest $request The request data.
* @return ResponseInterface The response data.
*/
public function delete(ServerRequest $request): ResponseInterface;
/**
* Handle the request and return a response.
*
* @param ServerRequest $request The request data.
* @return ResponseInterface The response data.
*/
public function patch(ServerRequest $request): ResponseInterface;
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
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\Models\Model;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum;
use OpenApi\Attributes as OA;
/**
* Class HealthcheckController
*
* Handles health check requests to verify database and cache connectivity.
*
* @package Siteworxpro\App\Controllers
*/
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 {
/** @var PostgresConnection $conn */
$conn = Model::getConnectionResolver()->connection();
$conn->getPdo()->exec('SELECT 1');
$response = Redis::ping();
if ($response->getPayload() !== 'PONG') {
throw new \Exception('Redis ping failed');
}
} catch (\Exception $e) {
return JsonResponseFactory::createJsonResponse(
[
'status_code' => CodesEnum::SERVICE_UNAVAILABLE->value,
'message' => 'Healthcheck Failed',
'error' => $e->getMessage(),
],
CodesEnum::SERVICE_UNAVAILABLE
);
}
return JsonResponseFactory::createJsonResponse(
new GenericResponse('Healthcheck OK', CodesEnum::OK->value)
);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Attributes\Guards;
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;
/**
* Class IndexController
*
* This class handles the index route of the application.
*/
class IndexController extends Controller
{
/**
* Handles the GET request for the index route.
*
* @throws \JsonException
*/
#[Guards\Jwt]
#[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(new GenericResponse('Server is running'));
}
/**
* 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(new GenericResponse('POST request received'));
}
}

View File

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

View File

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

View File

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

246
src/Events/Dispatcher.php Normal file
View File

@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events;
use Illuminate\Contracts\Events\Dispatcher as DispatcherContract;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;
use Siteworxpro\App\Attributes\Events\ListensFor;
use function React\Async\await;
use function React\Async\coroutine;
/**
* Class Dispatcher
*
* A custom event dispatcher that automatically registers event listeners
* based on the ListensFor attribute.
*
* @package Siteworxpro\App\Events
*/
class Dispatcher implements DispatcherContract, Arrayable
{
/**
* @var array $listeners Registered event listeners
*/
private array $listeners = [];
/**
* @var Collection $pushed Pushed events collection
*/
private Collection $pushed;
private array $subscribers = [];
/**
* @var string LISTENERS_NAMESPACE The namespace where listeners are located
*/
private const string LISTENERS_NAMESPACE = 'Siteworxpro\\App\\Events\\Listeners\\';
public function __construct()
{
$this->pushed = new Collection();
$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.
*
* @return void
*/
private function registerListeners(): void
{
// traverse the Listeners directory and register all listeners
$listenersPath = __DIR__ . '/Listeners';
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($listenersPath));
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$relativePath = str_replace($listenersPath . '/', '', $file->getPathname());
$className = self::LISTENERS_NAMESPACE . str_replace(['/', '.php'], ['\\', ''], $relativePath);
if (class_exists($className)) {
$reflectionClass = new \ReflectionClass($className);
$attributes = $reflectionClass->getAttributes(ListensFor::class);
foreach ($attributes as $attribute) {
$instance = $attribute->newInstance();
$eventClass = $instance->eventClass;
$this->listen($eventClass, new $className());
}
}
}
}
}
/**
* Register a listener for the given events.
*
* @param $events
* @param $listener
* @return void
*/
public function listen($events, $listener = null): void
{
$this->listeners[$events][] = $listener;
}
/**
* Check if there are listeners for the given event.
*
* @param $eventName
* @return bool
*/
public function hasListeners($eventName): bool
{
return isset($this->listeners[$eventName]) && !empty($this->listeners[$eventName]);
}
/**
* Subscribe a subscriber to the dispatcher.
*
* @param Arrayable $subscriber
* @return void
*/
public function subscribe($subscriber): void
{
$this->subscribers[] = $subscriber;
}
/**
* Dispatch an event and halt on the first non-null response.
*
* @param $event
* @param array $payload
* @return array|null
* @throws \Throwable
*/
public function until($event, $payload = []): array|null
{
return $this->dispatch($event, $payload, true);
}
/**
* Dispatch an event to its listeners.
*
* @param $event
* @param array $payload
* @param bool $halt
* @return array|null
* @throws \Throwable
*/
public function dispatch($event, $payload = [], $halt = false): array|null
{
if (is_object($event)) {
$eventClass = get_class($event);
} else {
$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 await($promise);
}
$responses = [];
foreach ($listeners as $listener) {
$response = $listener($event, $payload);
$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;
}
/**
* Push an event to be dispatched later.
*
* @param $event
* @param array $payload
* @return void
*/
public function push($event, $payload = []): void
{
$this->pushed->put($event, $payload);
}
/**
* Flush a pushed event, dispatching it if it exists.
*
* @param $event
* @return void
* @throws \Throwable
*/
public function flush($event): void
{
if ($this->pushed->has($event)) {
$payload = $this->pushed->get($event);
$this->dispatch($event, $payload);
$this->pushed->forget([$event]);
}
}
/**
* Forget a pushed event without dispatching it.
*
* @param $event
* @return void
*/
public function forget($event): void
{
$this->pushed->forget([$event]);
}
/**
* Forget all pushed events.
*
* @return void
*/
public function forgetPushed(): void
{
$this->pushed = new Collection();
}
public function toArray(): array
{
return $this->listeners;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Listeners\Database;
use Illuminate\Database\Events\ConnectionEstablished;
use Illuminate\Database\Events\ConnectionEvent;
use Siteworxpro\App\Attributes\Events\ListensFor;
use Siteworxpro\App\Events\Listeners\Listener;
use Siteworxpro\App\Services\Facades\Logger;
/**
* Class Connected
* @package Siteworxpro\App\Events\Listeners\Database
*/
#[ListensFor(ConnectionEstablished::class)]
class Connected extends Listener
{
/**
* @param mixed $event
* @param array $payload
* @return 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]);
return null;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Listeners;
/**
* Class Listener
*
* @package Siteworxpro\App\Events\Listeners
*/
abstract class Listener implements ListenerInterface
{
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Listeners;
/**
* Interface ListenerInterface
* @package Siteworxpro\App\Events\Listeners
*/
interface ListenerInterface
{
/**
* @param mixed $event
* @param array $payload
* @return mixed
*/
public function __invoke(mixed $event, array $payload = []): mixed;
}

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

View File

@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Facades;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Facades\Facade;
use Siteworx\Config\Exception\EmptyDirectoryException;
use Siteworx\Config\Exception\FileNotFoundException;
use Siteworx\Config\Exception\UnsupportedFormatException;
/**
* Class Config
*
* This class serves as a facade for the configuration settings of the application.
* It extends the Facade class from the Illuminate\Support\Facades namespace.
*
* @method static bool | string | int get(string $key) Retrieve the configuration value for the given key.
*
* @package Siteworx\App\Facades
*/
class Config extends Facade
{
protected static $cached = false;
/**
* @throws UnsupportedFormatException
* @throws FileNotFoundException
* @throws EmptyDirectoryException
*/
public static function getFacadeRoot(): \Siteworx\Config\Config
{
if (self::$resolvedInstance !== null) {
try {
$config = self::resolveFacadeInstance(self::getFacadeAccessor());
if ($config instanceof \Siteworx\Config\Config) {
return $config;
}
} catch (BindingResolutionException) {
}
}
return \Siteworx\Config\Config::load(__DIR__ . '/../../config.php');
}
/**
* Get the registered name of the component.
*
* @return string The name of the component.
*/
protected static function getFacadeAccessor(): string
{
return \Siteworx\Config\Config::class;
}
}

View File

@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Facades;
use Illuminate\Support\Facades\Facade;
use RoadRunner\Logger\Logger as RRLogger;
use Spiral\Goridge\RPC\RPC;
/**
* Class Logger
*
* This class serves as a facade for the Monolog logger.
* It extends the Facade class from the Illuminate\Support\Facades namespace.
*
* @method static info(string $message, array $context = []) Log an informational message.
* @method static error(string $message, array $context = []) Log an error message.
* @method static warning(string $message, array $context = []) Log a warning message.
*
* @package Siteworxpro\App\Facades
*/
class Logger extends Facade
{
public static function getFacadeRoot(): RRLogger
{
if (self::$resolvedInstance !== null) {
$logger = self::resolveFacadeInstance(self::getFacadeAccessor());
if ($logger instanceof RRLogger) {
return $logger;
}
}
$rpc = RPC::create('tcp://127.0.0.1:6001');
return new RRLogger($rpc);
}
/**
* Get the registered name of the component.
*
* @return string The name of the component.
*/
protected static function getFacadeAccessor(): string
{
return RRLogger::class;
}
}

View File

@@ -4,6 +4,10 @@ declare(strict_types=1);
namespace Siteworxpro\App\Helpers;
/**
* Class Env
* @package Siteworxpro\App\Helpers
*/
abstract class Env
{
/**

22
src/Helpers/Ulid.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Helpers;
/**
* Class Ulid
* @package Siteworxpro\App\Helpers
*/
class Ulid
{
/**
* Generate a ULID string
*
* @return string
*/
public static function generate(): string
{
return \Ulid\Ulid::generate()->getRandomness();
}
}

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,7 +4,9 @@ declare(strict_types=1);
namespace Siteworxpro\App\Http;
use Illuminate\Contracts\Support\Arrayable;
use Nyholm\Psr7\Response;
use Siteworxpro\HttpStatus\CodesEnum;
/**
* Class JsonResponseFactory
@@ -16,15 +18,21 @@ class JsonResponseFactory
/**
* Create a JSON response with the given data and status code.
*
* @param array $data The data to include in the response.
* @param int $statusCode The HTTP status code for 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, int $statusCode = 200): 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,
status: $statusCode->value,
headers: [
'Content-Type' => 'application/json',
],

View File

@@ -9,7 +9,7 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\App\Facades\Config;
use Siteworxpro\App\Services\Facades\Config;
/**
* Class CorsMiddleware

View File

@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
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;
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
use Lcobucci\JWT\Validation\Constraint\PermittedFor;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Lcobucci\JWT\Validation\Constraint\StrictValidAt;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use League\Route\Dispatcher;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
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;
/**
* JWT authorization middleware.
*
* Applies JWT validation to controller actions annotated with `Jwt` attribute.
* Flow:
* - Resolve the targeted controller and method for the current route.
* - If the method has `Jwt`, read the `Authorization` header and parse the Bearer token.
* - Validate signature, time constraints, issuer\(\) and audience\(\) based on attribute and config.
* - On success, attach all token claims to the request as attributes.
* - On failure, return a 401 JSON response with validation errors.
*
* Configuration:
* - `jwt.signing_key`: key material or `file://` path to key.
* - `jwt.strict_validation`: bool toggling strict vs loose time validation.
*/
class JwtMiddleware extends Middleware
{
/**
* Process the incoming request.
*
* If the matched controller method is annotated with `Jwt`, validates the token and
* augments the request with claims on success. Otherwise, just delegates to the next handler.
*
* @param ServerRequestInterface $request PSR-7 request instance.
* @param RequestHandlerInterface|Dispatcher $handler Next middleware or route dispatcher.
*
* @return ResponseInterface Response produced by the next handler or a 401 JSON response.
*
* @throws \JsonException On JSON error response encoding issues.
* @throws \Exception On unexpected reflection or JWT parsing issues.
*/
public function process(
ServerRequestInterface $request,
RequestHandlerInterface|Dispatcher $handler
): ResponseInterface {
// Resolve the callable \[Controller, method] for the current route.
$callable = $this->extractRouteCallable($handler);
if ($callable === null) {
return $handler->handle($request);
}
/** @var Controller $class */
[$class, $method] = $callable;
if (class_exists($class::class)) {
$reflectionClass = new \ReflectionClass($class);
if ($reflectionClass->hasMethod($method)) {
$reflectionMethod = $reflectionClass->getMethod($method);
// Read `Jwt` attribute on the controller method.
$attributes = $reflectionMethod->getAttributes(Jwt::class);
// If no `Jwt` attribute, do not enforce auth here.
if (empty($attributes)) {
return $handler->handle($request);
}
// Extract Bearer token from Authorization header.
$token = str_replace('Bearer ', '', $request->getHeaderLine('Authorization'));
if (empty($token)) {
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
'message' => 'Unauthorized: Missing token',
], CodesEnum::UNAUTHORIZED);
}
// Aggregate required issuers and audience from attributes.
$requiredIssuers = [];
$requiredAudience = '';
foreach ($attributes as $attribute) {
/** @var Jwt $jwtInstance */
$jwtInstance = $attribute->newInstance();
if ($jwtInstance->getAudience() !== '') {
$requiredAudience = $jwtInstance->getAudience();
}
$requiredIssuers[] = $jwtInstance->getIssuer();
}
try {
// Parse and validate the token with signature, time, issuer and audience constraints.
$jwt = new JwtFacade()->parse(
$token,
$this->getSignedWith($token),
Config::get('jwt.strict_validation') ?
new StrictValidAt(new WrapperClock(Carbon::now())) :
new LooseValidAt(new WrapperClock(Carbon::now())),
new IssuedBy(...$requiredIssuers),
new PermittedFor($requiredAudience)
);
} catch (RequiredConstraintsViolated $exception) {
// Collect human-readable violations to return to the client.
$violations = [];
foreach ($exception->violations() as $violation) {
$violations[] = $violation->getMessage();
}
return JsonResponseFactory::createJsonResponse([
'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' => 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.
foreach ($jwt->claims()->all() as $item => $value) {
$request = $request->withAttribute($item, $value);
}
}
}
return $handler->handle($request);
}
/**
* Build the signature validation constraint from configured key.
*
* - If the configured key content includes the string `PUBLIC KEY`, use RSA SHA-256.
* - Otherwise assume an HMAC SHA-256 shared secret.
* - Supports raw key strings or `file://` paths.
*
* @return SignedWith Signature constraint used during JWT parsing.
*
* @throws \RuntimeException When no signing key is configured.
* @throws \JsonException
*/
private function getSignedWith(string $token): SignedWith
{
$keyConfig = Config::get('jwt.signing_key');
if ($keyConfig === null) {
throw new \RuntimeException('JWT signing key is not configured.');
}
// 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($keyConfig);
}
// Heuristic: if PEM public key content is detected, use RSA; otherwise use HMAC.
if (str_contains($key->contents(), 'PUBLIC KEY')) {
return new SignedWith(new Sha256(), $key);
}
return new SignedWith(new Hmac256(), $key);
}
private function getJwksKey(string $url, string $keyId): Key
{
$cached = Redis::get('jwks_key_' . $keyId);
if ($cached !== null) {
return InMemory::plainText($cached);
}
$openIdConfig = Guzzle::get($url);
$body = json_decode($openIdConfig->getBody()->getContents(), true, JSON_THROW_ON_ERROR);
$jwksUri = $body['jwks_uri'] ?? '';
if (empty($jwksUri)) {
throw new \RuntimeException('JWKS URI not found in OpenID configuration.');
}
$jwksResponse = Guzzle::get($jwksUri);
$jwksBody = json_decode(
$jwksResponse->getBody()->getContents(),
true,
JSON_THROW_ON_ERROR
);
// For simplicity, we take the first key in the JWKS.
$firstKey = array_filter(
$jwksBody['keys'],
fn($key) => $key['kid'] === $keyId
)[0] ?? $jwksBody['keys'][0] ?? null;
if (empty($firstKey)) {
throw new \RuntimeException('No matching key found in JWKS for key ID: ' . $keyId);
}
$n = $firstKey['n'];
$e = $firstKey['e'];
$publicKeyPem = "-----BEGIN PUBLIC KEY-----\n" .
chunk_split(base64_encode($this->convertJwkToPem($n, $e)), 64) .
"-----END PUBLIC KEY-----\n";
Redis::set('jwks_key_' . $keyId, $publicKeyPem, 'EX', 3600);
return InMemory::plainText($publicKeyPem);
}
/**
* Build a DER-encoded SubjectPublicKeyInfo from JWK 'n' and 'e'.
* Returns raw DER bytes; caller base64-encodes and wraps with PEM headers.
*/
private function convertJwkToPem(string $n, string $e): string
{
$modulus = $this->base64UrlDecode($n);
$exponent = $this->base64UrlDecode($e);
$derN = $this->derEncodeInteger($modulus);
$derE = $this->derEncodeInteger($exponent);
// RSAPublicKey (PKCS#1): SEQUENCE { n INTEGER, e INTEGER }
$rsaPublicKey = $this->derEncodeSequence($derN . $derE);
// AlgorithmIdentifier for rsaEncryption: 1.2.840.113549.1.1.1 with NULL
$algId = hex2bin('300d06092a864886f70d0101010500');
// SubjectPublicKey (SPKI) BIT STRING, 0 unused bits + RSAPublicKey
$subjectPublicKey = $this->derEncodeBitString($rsaPublicKey);
// SubjectPublicKeyInfo: SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }
return $this->derEncodeSequence($algId . $subjectPublicKey);
}
private function base64UrlDecode(string $data): string
{
$data = strtr($data, '-_', '+/');
$pad = strlen($data) % 4;
if ($pad) {
$data .= str_repeat('=', 4 - $pad);
}
return base64_decode($data);
}
private function derEncodeLength(int $len): string
{
if ($len < 0x80) {
return chr($len);
}
$bytes = '';
while ($len > 0) {
$bytes = chr($len & 0xFF) . $bytes;
$len >>= 8;
}
return chr(0x80 | strlen($bytes)) . $bytes;
}
private function derEncodeInteger(string $bytes): string
{
// Remove leading zeroes
$bytes = ltrim($bytes, "\x00");
if ($bytes === '') {
$bytes = "\x00";
}
// Ensure positive INTEGER (prepend 0x00 if MSB set)
if ((ord($bytes[0]) & 0x80) !== 0) {
$bytes = "\x00" . $bytes;
}
return "\x02" . $this->derEncodeLength(strlen($bytes)) . $bytes;
}
private function derEncodeSequence(string $bytes): string
{
return "\x30" . $this->derEncodeLength(strlen($bytes)) . $bytes;
}
private function derEncodeBitString(string $bytes): string
{
// 0 unused bits + data
$payload = "\x00" . $bytes;
return "\x03" . $this->derEncodeLength(strlen($payload)) . $payload;
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Http\Middleware;
use League\Route\Dispatcher;
use League\Route\Route;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Base middleware helper for extracting route callables.
*
* This abstract middleware provides a utility method to inspect a League\Route
* dispatcher and obtain the underlying route callable as a [class, method] tuple.
*
* @package Siteworxpro\App\Http\Middleware
*/
abstract class Middleware implements MiddlewareInterface
{
/**
* Extract the route callable [class, method] from a League\Route dispatcher.
*
* When the provided handler is a League\Route\Dispatcher, this inspects its
* middleware stack, looks at the last segment (the resolved Route), and
* attempts to normalize its callable into a [class, method] pair.
*
* Supported callable forms:
* - array callable: [object|class-string, method-string]
* - string callable: "ClassName::methodName"
*
* Returns null when the handler is not a Dispatcher, the stack is empty,
* or the callable cannot be parsed.
*
* @param RequestHandlerInterface|Dispatcher $handler The downstream handler or dispatcher.
*
* @return array{0: class-string|object|null, 1: string|null}|null Tuple of [class|object, method] or null.
*/
protected function extractRouteCallable(
RequestHandlerInterface|Dispatcher $handler
): array|null {
// Only proceed if this is a League\Route dispatcher.
if (!$handler instanceof Dispatcher) {
return null;
}
/** @var Route | null $lastSegment */
// Retrieve the last middleware in the stack, which should be the Route.
$lastSegment = array_last($handler->getMiddlewareStack());
if ($lastSegment === null) {
return null;
}
// Obtain the callable associated with the route.
$callable = $lastSegment->getCallable();
$class = null;
$method = null;
// Handle array callable: [object|class-string, 'method']
if (is_array($callable) && count($callable) === 2) {
[$class, $method] = $callable;
} elseif (is_string($callable)) {
// Handle string callable: 'ClassName::methodName'
[$class, $method] = explode('::', $callable);
}
return [$class, $method];
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Http\Middleware;
use League\Route\Dispatcher;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
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;
/**
* Middleware that enforces scope-based access control on controller actions.
*
* It inspects PHP 8 attributes of type \`Scope\` applied to the resolved controller method,
* compares the required scopes with the user scopes provided on the request attribute \`scopes\`,
* and returns a 403 JSON response when any required scope is missing.
*
* If the route callable cannot be resolved, or no scope is required, the request is passed through.
*
* @see Scope
*/
class ScopeMiddleware extends Middleware
{
/**
* Resolve the route callable, read any \`Scope\` attributes, and enforce required scopes.
*
* Expected user scopes are provided on the request under the attribute name \`scopes\`
* as an array of strings.
*
* @param ServerRequestInterface $request Incoming PSR-7 request (expects \`scopes\` attribute).
* @param RequestHandlerInterface|Dispatcher $handler Next handler or League\Route dispatcher.
*
* @return ResponseInterface A 403 JSON response when scopes are insufficient; otherwise the handler response.
*
* @throws \JsonException If encoding the JSON error response fails.
* @throws \ReflectionException If reflection on the controller or method fails.
*/
public function process(
ServerRequestInterface $request,
RequestHandlerInterface|Dispatcher $handler
): ResponseInterface {
// Attempt to resolve the route's callable [Controller instance, method name].
$callable = $this->extractRouteCallable($handler);
if ($callable === null) {
// If no callable is available, delegate to the next handler.
return $handler->handle($request);
}
/** @var Controller $class Controller instance resolved from the route. */
[$class, $method] = $callable;
// 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 = array_merge($requiredScopes, $scopeInstance->getScopes());
// 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 (
(!$requireAll && array_intersect($userScopes, $requiredScopes) === []) ||
($requireAll && array_diff($requiredScopes, $userScopes) !== [])
) {
return JsonResponseFactory::createJsonResponse([
'error' => 'insufficient_scope',
'error_description' =>
'The request requires higher privileges than provided by the access token.'
], CodesEnum::FORBIDDEN);
}
}
}
// All checks passed; continue down the middleware pipeline.
return $handler->handle($request);
}
}

View File

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

View File

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

View File

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

95
src/Kernel.php Normal file
View File

@@ -0,0 +1,95 @@
<?php
namespace Siteworxpro\App;
use Illuminate\Container\Container;
use Illuminate\Database\Capsule\Manager;
use Illuminate\Support\ServiceProvider;
use Siteworx\Config\Config as SWConfig;
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\DispatcherServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\LoggerServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\RedisServiceProvider;
/**
* Class Kernel
*
* The Kernel class is responsible for bootstrapping the application by
* initializing service providers and setting up the database connection.
*
* @package Siteworxpro\App
*/
class Kernel
{
/**
* List of service providers to be registered during bootstrapping.
*
* @var array
*/
private static array $serviceProviders = [
LoggerServiceProvider::class,
RedisServiceProvider::class,
DispatcherServiceProvider::class,
BrokerServiceProvider::class
];
/**
* Bootstraps the server by initializing the PSR-7 worker and router.
*
* This method sets up the PSR-7 worker and router instances, and registers
* the routes for the server. It should be called in the constructor of
* subclasses to ensure proper initialization.
*
* @return void
* @throws \ReflectionException
*/
public static function boot(): void
{
$container = new Container();
Facade::setFacadeContainer($container);
// Bind the container to the Config facade first so that it can be used by service providers
$container->bind(SWConfig::class, function () {
return SWConfig::load(__DIR__ . '/../config.php');
});
foreach (self::$serviceProviders as $serviceProvider) {
if (class_exists($serviceProvider)) {
$provider = new $serviceProvider($container);
if ($provider instanceof ServiceProvider) {
$provider->register();
} else {
throw new \RuntimeException(sprintf(
'Service provider %s is not an instance of ServiceProvider.',
$serviceProvider
));
}
} else {
throw new \RuntimeException(sprintf('Service provider %s not found.', $serviceProvider));
}
}
self::bootModelCapsule();
}
/**
* Bootstraps the model capsule for database connections.
*
* This method sets up the database connection using the Eloquent ORM.
* It retrieves the database configuration from the Config facade and
* initializes the Eloquent capsule manager.
*
* @return void
*/
private static function bootModelCapsule(): void
{
$capsule = new Manager();
$capsule->setEventDispatcher(Dispatcher::getFacadeRoot());
$capsule->addConnection(Config::get('db'));
$capsule->setAsGlobal();
$capsule->bootEloquent();
}
}

236
src/Log/Logger.php Normal file
View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Log;
use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\StreamHandler;
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\Services\Facades\RoadRunnerLogger;
/**
* Logger implementation that conforms to PSR-3 (`Psr\Log\LoggerInterface`).
*
* Behavior:
* - If environment indicates RoadRunner RPC (`$_SERVER['RR_RPC']`), logs are forwarded
* to a RoadRunner RPC logger (`RoadRunner\Logger\Logger`) created via Goridge RPC.
* - Otherwise, logs are written to `php://stdout` using Monolog with a JSON formatter.
* - Messages below the configured threshold are ignored (level filtering).
*
* Supported PSR-3 levels are mapped to an internal numeric ordering in `$levels`.
* When using the RPC logger, levels are translated to the respective RPC methods
* (debug, info, warning, error). When using Monolog, the numeric mapping is used
* as the numeric level passed to Monolog's `log` method.
*/
class Logger implements LoggerInterface
{
/**
* RoadRunner RPC logger instance when running under RoadRunner.
*
* @var RRLogger | LoggerInterface | null
*/
private RRLogger | LoggerInterface | null $rpcLogger = null;
/**
* Monolog logger used as a fallback to write JSON-formatted logs to stdout.
*
* @var \Monolog\Logger
*/
private \Monolog\Logger $monologLogger;
/**
* Numeric ordering for PSR-3 log levels.
*
* Lower numbers represent higher severity. This mapping is used for filtering
* messages according to the configured minimum level and for Monolog numeric level.
*
* @var array<string,int>
*/
private array $levels = [
LogLevel::EMERGENCY => 0,
LogLevel::ALERT => 1,
LogLevel::CRITICAL => 2,
LogLevel::ERROR => 3,
LogLevel::WARNING => 4,
LogLevel::NOTICE => 5,
LogLevel::INFO => 6,
LogLevel::DEBUG => 7,
];
/**
* Create a new Logger.
*
* @param string $level Minimum level to log (PSR-3 level string). Messages with
* a higher numeric value in `$levels` will be ignored.
*
* @param resource | null $streamOutput Optional stream handler for Monolog.
*
* The default is `LogLevel::DEBUG` (log everything).
*
* If `$_SERVER['RR_RPC']` is set, an RPC connection will be attempted at
* $_SERVER['RR_RPC'] and a RoadRunner RPC logger will be used.
*
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function __construct(
private readonly string $level = LogLevel::DEBUG,
$streamOutput = null,
) {
if (isset($_SERVER['RR_RPC'])) {
$this->rpcLogger = RoadRunnerLogger::getFacadeRoot();
}
$this->monologLogger = new \Monolog\Logger('app_logger');
$formatter = new JsonFormatter();
$stream = $streamOutput ?? 'php://stdout';
$this->monologLogger->pushHandler(new StreamHandler($stream)->setFormatter($formatter));
}
/**
* System is unusable.
*
* @param \Stringable|string $message
* @param array $context
*/
public function emergency(\Stringable|string $message, array $context = []): void
{
$this->log(LogLevel::EMERGENCY, $message, $context);
}
/**
* Action must be taken immediately.
*
* @param \Stringable|string $message
* @param array $context
*/
public function alert(\Stringable|string $message, array $context = []): void
{
$this->log(LogLevel::ALERT, $message, $context);
}
/**
* Critical conditions.
*
* @param \Stringable|string $message
* @param array $context
*/
public function critical(\Stringable|string $message, array $context = []): void
{
$this->log(LogLevel::CRITICAL, $message, $context);
}
/**
* Runtime errors that do not require immediate action but should typically be logged and monitored.
*
* @param \Stringable|string $message
* @param array $context
*/
public function error(\Stringable|string $message, array $context = []): void
{
$this->log(LogLevel::ERROR, $message, $context);
}
/**
* Exceptional occurrences that are not errors.
*
* @param \Stringable|string $message
* @param array $context
*/
public function warning(\Stringable|string $message, array $context = []): void
{
$this->log(LogLevel::WARNING, $message, $context);
}
/**
* Normal but significant events.
*
* @param \Stringable|string $message
* @param array $context
*/
public function notice(\Stringable|string $message, array $context = []): void
{
$this->log(LogLevel::NOTICE, $message, $context);
}
/**
* Interesting events.
*
* @param \Stringable|string $message
* @param array $context
*/
public function info(\Stringable|string $message, array $context = []): void
{
$this->log(LogLevel::INFO, $message, $context);
}
/**
* Detailed debug information.
*
* @param \Stringable|string $message
* @param array $context
*/
public function debug(\Stringable|string $message, array $context = []): void
{
$this->log(LogLevel::DEBUG, $message, $context);
}
/**
* Logs with an arbitrary level.
*
* Behavior details:
* - If the provided `$level` maps to a numeric value greater than the configured
* minimum level, the message is discarded (filtered).
* - If an RPC logger is available, the message is forwarded to the RPC logger
* using a method chosen by level (debug, info, warning, error).
* - Otherwise, the message is written to Monolog using the numeric mapping.
*
* Notes:
* - `$level` should be a PSR-3 level string (values defined in `Psr\Log\LogLevel`).
* - If an unknown level string is passed, accessing `$this->levels[$level]` may
* trigger a PHP notice or undefined index. Ensure callers use valid PSR-3 levels.
*
* @param mixed $level PSR-3 log level (string)
* @param \Stringable|string $message
* @param array $context
*/
public function log($level, \Stringable|string $message, array $context = []): void
{
if (isset($this->levels[$level]) && $this->levels[$level] > $this->levels[$this->level]) {
return;
}
if ($this->rpcLogger) {
switch ($level) {
case LogLevel::DEBUG:
$this->rpcLogger->debug((string)$message, $context);
break;
case LogLevel::NOTICE:
case LogLevel::INFO:
$this->rpcLogger->info((string)$message, $context);
break;
case LogLevel::WARNING:
$this->rpcLogger->warning((string)$message, $context);
break;
case LogLevel::CRITICAL:
case LogLevel::ERROR:
case LogLevel::ALERT:
case LogLevel::EMERGENCY:
$this->rpcLogger->error((string)$message, $context);
break;
default:
$this->rpcLogger->log($level, (string)$message, $context);
break;
}
return;
}
$this->monologLogger->log($this->levels[$level], (string)$message, $context);
}
}

View File

@@ -6,6 +6,12 @@ namespace Siteworxpro\App\Models;
use Illuminate\Database\Eloquent\Model as ORM;
/**
* Class Model
*
* @package Siteworxpro\App\Models
*/
abstract class Model extends ORM
{
protected $dateFormat = 'Y-m-d H:i:s';
}

77
src/Models/User.php Normal file
View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models;
use Carbon\Carbon;
use OpenApi\Attributes as OA;
use Siteworxpro\App\Helpers\Ulid;
/**
* Class User
*
* @property-read string $id
* @property string $first_name
* @property string $last_name
* @property string $email
* @property string $password
* @property Carbon $created_at
*
* @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 = [
'created_at' => 'datetime',
];
protected $hidden = [
'password',
];
protected $fillable = [
'first_name',
'last_name',
'email',
'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";
}
public function getFormattedEmailAttribute(): string
{
return sprintf(
'%s <%s>',
$this->getFullNameAttribute(),
strtolower($this->email)
);
}
}

309
src/Services/Facade.php Normal file
View File

@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\HigherOrderTapProxy;
use Illuminate\Support\Testing\Fakes\Fake;
use Mockery;
use Mockery\Expectation;
use Mockery\ExpectationInterface;
use Mockery\LegacyMockInterface;
use Mockery\MockInterface;
class Facade
{
/**
* The application instance being facaded.
*
* @var Container | null
*/
protected static ?Container $container = null;
/**
* The resolved object instances.
*
* @var array
*/
protected static array $resolvedInstance;
/**
* Indicates if the resolved instance should be cached.
*
* @var bool
*/
protected static bool $cached = true;
/**
* Run a Closure when the facade has been resolved.
*
* @param \Closure $callback
* @return void
*/
public static function resolved(\Closure $callback): void
{
$accessor = static::getFacadeAccessor();
if (static::$container->resolved($accessor) === true) {
$callback(static::getFacadeRoot(), static::$container);
}
static::$container->afterResolving($accessor, function ($service, $app) use ($callback) {
$callback($service, $app);
});
}
/**
* 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.
*
* @return MockInterface
*/
public static function partialMock(): MockInterface
{
$name = static::getFacadeAccessor();
$mock = static::isMock()
? static::$resolvedInstance[$name]
: static::createFreshMockInstance();
return $mock->makePartial();
}
/**
* Initiate a mock expectation on the facade.
*
* @return Expectation|ExpectationInterface
*/
public static function shouldReceive(): Mockery\Expectation | Mockery\ExpectationInterface
{
$name = static::getFacadeAccessor();
$mock = static::isMock()
? static::$resolvedInstance[$name]
: static::createFreshMockInstance();
return $mock->shouldReceive(...func_get_args());
}
/**
* Initiate a mock expectation on the facade.
*
* @return Expectation|ExpectationInterface
*/
public static function expects(): Mockery\Expectation | Mockery\ExpectationInterface
{
$name = static::getFacadeAccessor();
$mock = static::isMock()
? static::$resolvedInstance[$name]
: static::createFreshMockInstance();
return $mock->expects(...func_get_args());
}
/**
* Create a fresh mock instance for the given class.
*
* @return MockInterface|LegacyMockInterface
*/
protected static function createFreshMockInstance(): MockInterface | LegacyMockInterface
{
return tap(static::createMock(), function ($mock) {
static::swap($mock);
$mock->shouldAllowMockingProtectedMethods();
});
}
/**
* Create a fresh mock instance for the given class.
*
* @return MockInterface
*/
protected static function createMock(): MockInterface
{
$class = static::getMockableClass();
return $class ? Mockery::mock($class) : Mockery::mock();
}
/**
* Determines whether a mock is set as the instance of the facade.
*
* @return bool
*/
protected static function isMock(): bool
{
$name = static::getFacadeAccessor();
return isset(static::$resolvedInstance[$name]) &&
static::$resolvedInstance[$name] instanceof LegacyMockInterface;
}
/**
* Get the mockable class for the bound instance.
*
* @return string|null
*/
protected static function getMockableClass(): ?string
{
if ($root = static::getFacadeRoot()) {
return get_class($root);
}
return null;
}
/**
* Hotswap the underlying instance behind the facade.
*
* @param mixed $instance
* @return void
*/
public static function swap(mixed $instance): void
{
static::$resolvedInstance[static::getFacadeAccessor()] = $instance;
if (isset(static::$container)) {
static::$container->instance(static::getFacadeAccessor(), $instance);
}
}
/**
* 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.
*
* @return mixed
*/
public static function getFacadeRoot(): mixed
{
return static::resolveFacadeInstance(static::getFacadeAccessor());
}
/**
* Get the registered name of the component.
*
* @return string
*
* @throws \RuntimeException
*/
protected static function getFacadeAccessor(): string
{
throw new \RuntimeException('Facade does not implement getFacadeAccessor method.');
}
/**
* Resolve the facade root instance from the container.
*
* @param string $name
* @return mixed
*/
protected static function resolveFacadeInstance(string $name): mixed
{
if (isset(static::$resolvedInstance[$name])) {
return static::$resolvedInstance[$name];
}
if (static::$container) {
if (static::$cached) {
return static::$resolvedInstance[$name] = static::$container[$name];
}
return static::$container[$name];
}
return null;
}
/**
* Clear a resolved facade instance.
*
* @param string $name
* @return void
*/
public static function clearResolvedInstance(string $name): void
{
unset(static::$resolvedInstance[$name]);
}
/**
* Clear all of the resolved instances.
*
* @return void
*/
public static function clearResolvedInstances(): void
{
static::$resolvedInstance = [];
}
/**
* Get the application instance behind the facade.
*/
public static function getFacadeContainer(): ?Container
{
return static::$container;
}
/**
* Set the application instance.
*
* @param Container | null $container
* @return void
*/
public static function setFacadeContainer(Container | null $container): void
{
static::$container = $container;
}
/**
* Handle dynamic, static calls to the object.
*
* @param string $method
* @param array $args
* @return mixed
*
* @throws \RuntimeException
*/
public static function __callStatic(string $method, array $args)
{
$instance = static::getFacadeRoot();
if (! $instance) {
throw new \RuntimeException('A facade root has not been set.');
}
return $instance->$method(...$args);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Services\Facade;
/**
* Broker Facade
*
* @method static void publish(Queue $queue, Message $message, int $delay = 0)
* @method static void publishLater(Queue $queue, Message $message, int $delay)
* @method static Message|null consume(Queue $queue)
*/
class Broker extends Facade
{
/**
* Get the registered name of the component.
*
* @return string The name of the component.
*/
protected static function getFacadeAccessor(): string
{
return \Siteworxpro\App\Async\Brokers\Broker::class;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use Siteworx\Config\Config as SwConfig;
use Siteworxpro\App\Services\Facade;
/**
* Class Config
*
* This class serves as a facade for the configuration settings of the application.
* 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
*/
class Config extends Facade
{
/**
* Get the registered name of the component.
*
* @return string The name of the component.
*/
protected static function getFacadeAccessor(): string
{
return SwConfig::class;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use Siteworxpro\App\Events\Dispatcher as DispatcherConcrete;
use Siteworxpro\App\Services\Facade;
/**
* Class Dispatcher
*
* A facade for the event dispatcher.
*
* @package Siteworxpro\App\Services\Facades
*
* @method static void listen(string $event, callable|string $listener)
* @method static void dispatch(object|string $event, array $payload = [], bool $halt = false)
* @method static void push(object|string $event, array $payload = [])
* @method static array|null until(object|string $event, array $payload = [])
* @method static bool hasListeners(string $eventName)
* @method static void subscribe(mixed $subscriber)
*/
class Dispatcher extends Facade
{
/**
* Get the registered name of the component.
*
* @return string The name of the component.
*/
protected static function getFacadeAccessor(): string
{
return DispatcherConcrete::class;
}
}

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

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use Siteworxpro\App\Services\Facade;
/**
* Class Logger
*
* This class serves as a facade for the Monolog logger.
* It extends the Facade class from the Illuminate\Support\Facades namespace.
*
* @method static debug(\Stringable|string $message, array $context = []) Log an informational message.
* @method static info(\Stringable|string $message, array $context = []) Log an informational message.
* @method static error(\Stringable|string $message, array $context = []) Log an error message.
* @method static warning(\Stringable|string $message, array $context = []) Log a warning message.
* @method static critical(\Stringable|string $message, array $context = []) Log a critical error message.
* @method static alert(\Stringable|string $message, array $context = []) Log an alert message.
* @method static emergency(\Stringable|string $message, array $context = []) Log an emergency message.
*
* @package Siteworxpro\App\Facades
*/
class Logger extends Facade
{
/**
* Get the registered name of the component.
*
* @return string The name of the component.
*/
protected static function getFacadeAccessor(): string
{
return \Siteworxpro\App\Log\Logger::class;
}
}

View File

@@ -2,11 +2,11 @@
declare(strict_types=1);
namespace Siteworxpro\App\Facades;
namespace Siteworxpro\App\Services\Facades;
use Illuminate\Support\Facades\Facade;
use Predis\Client;
use Predis\Response\Status;
use Siteworxpro\App\Services\Facade;
/**
* Facade for the Redis client.
@@ -18,28 +18,10 @@ use Predis\Response\Status;
* @method static Status|null set(string $key, $value, $expireResolution = null, $expireTTL = null, $flag = null)
* @method static array keys(string $pattern)
* @method static int del(string $key)
* @method static Status ping()
*/
class Redis extends Facade
{
public static function getFacadeRoot(): Client
{
if (self::$resolvedInstance !== null) {
$redis = self::resolveFacadeInstance(self::getFacadeAccessor());
if ($redis instanceof Client) {
return $redis;
}
}
// Create a new Redis client instance if not already resolved
return new Client([
'scheme' => 'tcp',
'host' => Config::get('redis.host'),
'port' => Config::get('redis.port'),
'database' => Config::get('redis.database'),
]);
}
/**
* Get the registered name of the component.
*

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use RoadRunner\Logger\Logger;
use Siteworxpro\App\Services\Facade;
use Spiral\Goridge\RPC\RPC;
class RoadRunnerLogger extends Facade
{
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public static function getFacadeRoot(): mixed
{
$container = static::getFacadeContainer();
if ($container && $container->has(Logger::class) === false) {
$rpc = RPC::create($_SERVER['RR_RPC']);
$logger = new Logger($rpc);
$container->bind(static::getFacadeAccessor(), function () use ($logger) {
return $logger;
});
return $logger;
}
return $container->get(Logger::class);
}
protected static function getFacadeAccessor(): string
{
return Logger::class;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\ServiceProviders;
use Illuminate\Support\ServiceProvider;
use Siteworxpro\App\Async\Brokers\Broker;
use Siteworxpro\App\Services\Facades\Config;
/**
* Class BrokerServiceProvider
*
* This service provider is responsible for binding the Broker implementation
* to the Laravel service container based on configuration settings.
*
* @package Siteworxpro\App\Services\ServiceProviders
*/
class BrokerServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* This method binds the Broker interface to a specific implementation
* based on the configuration defined in 'queue.broker' and 'queue.broker_config'.
*
* @return void
* @throws \RuntimeException if the specified broker class does not exist.
*/
public function register(): void
{
$this->app->singleton(Broker::class, function (): Broker {
$configName = Config::get('queue.broker');
$brokerConfig = Config::get('queue.broker_config.' . $configName) ?? [];
$brokerClass = Broker::BROKER_TYPES[$configName] ?? null;
if ($brokerClass && class_exists($brokerClass)) {
return new $brokerClass($brokerConfig);
}
throw new \RuntimeException("Broker class $brokerClass does not exist.");
});
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\ServiceProviders;
use Illuminate\Support\ServiceProvider;
use Siteworxpro\App\Events\Dispatcher;
/**
* Class DispatcherServiceProvider
*
* @package Siteworxpro\App\Services\ServiceProviders
*/
class DispatcherServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Dispatcher::class, function () {
return new Dispatcher();
});
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\ServiceProviders;
use Illuminate\Support\ServiceProvider;
use Siteworxpro\App\Log\Logger;
use Siteworxpro\App\Services\Facades\Config;
/**
* Class LoggerServiceProvider
*
* @package Siteworxpro\App\Services\ServiceProviders
*/
class LoggerServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Logger::class, function () {
return new Logger(Config::get('app.log_level'));
});
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\ServiceProviders;
use Illuminate\Support\ServiceProvider;
use Predis\Client;
use Siteworxpro\App\Services\Facades\Config;
/**
* Class RedisServiceProvider
*
* This service provider registers a Redis client as a singleton in the application container.
*
* @package Siteworxpro\App\Services\ServiceProviders
*/
class RedisServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Client::class, function () {
return new Client([
'scheme' => 'tcp',
'host' => Config::get('redis.host'),
'port' => Config::get('redis.port'),
'database' => Config::get('redis.database'),
'password' => Config::get('redis.password'),
]);
});
}
}

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,16 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Controllers;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\Tests\Unit;
abstract class AbstractController extends Unit
{
protected function getMockRequest(string $method = 'GET', string $uri = '/'): ServerRequest
{
return new ServerRequest($method, $uri);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Controllers;
use Siteworxpro\App\Controllers\Controller;
class ControllerTest extends AbstractController
{
public function testNotFoundExceptions()
{
$testClass = new TestClass();
$this->expectException(\League\Route\Http\Exception\NotFoundException::class);
$testClass->get($this->getMockRequest());
}
public function testNotFoundExceptionPost()
{
$testClass = new TestClass();
$this->expectException(\League\Route\Http\Exception\NotFoundException::class);
$testClass->post($this->getMockRequest());
}
public function testNotFoundExceptionPut()
{
$testClass = new TestClass();
$this->expectException(\League\Route\Http\Exception\NotFoundException::class);
$testClass->put($this->getMockRequest());
}
public function testNotFoundExceptionDelete()
{
$testClass = new TestClass();
$this->expectException(\League\Route\Http\Exception\NotFoundException::class);
$testClass->delete($this->getMockRequest());
}
public function testNotFoundExceptionPatch()
{
$testClass = new TestClass();
$this->expectException(\League\Route\Http\Exception\NotFoundException::class);
$testClass->patch($this->getMockRequest());
}
}
class TestClass extends Controller // phpcs:ignore
{
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Controllers;
use Siteworxpro\App\Controllers\IndexController;
class IndexControllerTest extends AbstractController
{
/**
* @throws \JsonException
*/
public function testGet(): void
{
$this->assertTrue(true);
$controller = new IndexController();
$response = $controller->get($this->getMockRequest());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('{"message":"Server is running","status_code":200}', (string)$response->getBody());
}
/**
* @throws \JsonException
*/
public function testPost(): void
{
$this->assertTrue(true);
$controller = new IndexController();
$response = $controller->post($this->getMockRequest());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('{"message":"POST request received","status_code":200}', (string)$response->getBody());
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Facades;
use Siteworxpro\App\Services\Facade;
use Siteworxpro\Tests\Unit;
abstract class AbstractFacade extends Unit
{
abstract protected function getFacadeClass(): string;
abstract protected function getConcrete(): string;
public function testFacadeAccessor(): void
{
/** @var Facade | string $class */
$class = $this->getFacadeClass();
$this->assertTrue(
method_exists($class, 'getFacadeAccessor'),
sprintf('The class %s must implement the method getFacadeAccessor.', $class)
);
$facade = $class::getFacadeRoot();
$this->assertNotNull(
$facade,
sprintf('The facade %s is not properly initialized.', $this->getConcrete())
);
}
}

Some files were not shown because too many files have changed in this diff Show More