diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index 22e39bff..00000000
Binary files a/.DS_Store and /dev/null differ
diff --git a/.docker/files/php-school-fpm/Dockerfile b/.docker/files/php-school-fpm/Dockerfile
index b6aa0abf..b7028137 100644
--- a/.docker/files/php-school-fpm/Dockerfile
+++ b/.docker/files/php-school-fpm/Dockerfile
@@ -1,18 +1,22 @@
-FROM php:8.1-fpm
+FROM php:8.3-fpm AS prod
RUN apt-get -qq update && apt-get install -qqy git zlib1g-dev libzip-dev \
&& rm -rf /var/lib/apt/lists/* \
- && docker-php-ext-install pdo pdo_mysql zip
+ && docker-php-ext-install pdo pdo_mysql zip sockets
# Composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
-#RUN pecl install xdebug && docker-php-ext-enable xdebug
-#ADD .docker/etc/php-xdebug.ini /usr/local/etc/php/conf.d/php-xdebug.ini
-
COPY . /var/www/html
WORKDIR /var/www/html
-RUN cd /var/www/html && composer install -q --no-dev -o
+COPY --from=php:8.1-cli /usr/local/bin/php-cgi /usr/local/bin/php-cgi
+
+RUN cd /var/www/html
+
+CMD ["php-fpm"]
+
+FROM prod AS debug
-CMD ["php-fpm"]
\ No newline at end of file
+RUN pecl install xdebug && docker-php-ext-enable xdebug
+ADD .docker/etc/php-xdebug.ini /usr/local/etc/php/conf.d/php-xdebug.ini
\ No newline at end of file
diff --git a/.env.dist b/.env.dist
index cb8469dd..1a220f52 100644
--- a/.env.dist
+++ b/.env.dist
@@ -1,10 +1,13 @@
-MYSQL_HOST=localhost
+MYSQL_HOST=db
MYSQL_DATABASE=phpschool
MYSQL_USER=phpschool
MYSQL_PASSWORD=phpschool
SEND_GRID_API_KEY=sendgripapikey
SEND_GRID_SENDER_EMAIL=phpschool.team@gmail.com
-REDIS_HOST=localhost
-CACHE.ENABLE=true
-CACHE.FPC.ENABLE=true
-DISPLAY_ERRORS=false
\ No newline at end of file
+REDIS_HOST=redis
+CACHE.ENABLE=false
+DISPLAY_ERRORS=true
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+GITHUB_TOKEN=
+DEV_MODE=true
\ No newline at end of file
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
new file mode 100644
index 00000000..867eec33
--- /dev/null
+++ b/.eslintrc.cjs
@@ -0,0 +1,17 @@
+/* eslint-env node */
+require('@rushstack/eslint-patch/modern-module-resolution')
+
+module.exports = {
+ root: true,
+ 'extends': [
+ 'plugin:vue/vue3-essential',
+ 'eslint:recommended',
+ '@vue/eslint-config-prettier/skip-formatting'
+ ],
+ parserOptions: {
+ ecmaVersion: 'latest'
+ },
+ env: {
+ node: true
+ }
+}
diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml
new file mode 100644
index 00000000..3845ee15
--- /dev/null
+++ b/.github/workflows/eslint.yml
@@ -0,0 +1,27 @@
+name: PhpSchool.io
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master, online ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ name: Eslint
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20.x'
+ cache: 'npm'
+
+ - name: Install NPM Dependencies
+ run: npm ci --no-audit --no-fund --prefer-offline
+
+ - name: Eslint
+ run: npm run lint
diff --git a/.github/workflows/phpschool.io.yml b/.github/workflows/php.yml
similarity index 66%
rename from .github/workflows/phpschool.io.yml
rename to .github/workflows/php.yml
index 64cd55a3..b51ffd98 100644
--- a/.github/workflows/phpschool.io.yml
+++ b/.github/workflows/php.yml
@@ -4,7 +4,7 @@ on:
push:
branches: [ master ]
pull_request:
- branches: [ master ]
+ branches: [ master, online ]
jobs:
build:
@@ -12,27 +12,27 @@ jobs:
strategy:
fail-fast: false
matrix:
- php: [8.1]
+ php: [8.2, 8.3]
name: PHP ${{ matrix.php }}
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
- tools: composer:v2
+ tools: composer:v2, cs2pr
- - name: Install Dependencies
+ - name: Install PHP Dependencies
run: composer install --prefer-dist
- name: Run phpunit tests
run: composer phpunit
- name: Run phpcs
- run: composer cs
+ run: composer cs:ci
- - name: Run psalm
- run: composer static
\ No newline at end of file
+ - name: Run PHPStan
+ run: composer static
diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml
new file mode 100644
index 00000000..8bcfe6d0
--- /dev/null
+++ b/.github/workflows/prettier.yml
@@ -0,0 +1,27 @@
+name: PhpSchool.io
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master, online ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ name: Prettier
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20.x'
+ cache: 'npm'
+
+ - name: Install NPM Dependencies
+ run: npm ci --no-audit --no-fund --prefer-offline
+
+ - name: Prettier
+ run: npm run prettier:ci
diff --git a/.gitignore b/.gitignore
index a5dd6fb3..067390d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
.idea
node_modules
.DS_Store
+.php-cs-fixer.cache
npm-debug.log
/vendor/
/logs/*
@@ -8,13 +9,17 @@ npm-debug.log
/cache/
/config/database.yml
/.phpunit.result.cache
-
+/*.sql
.docker/db/*
.docker/mysql/*
+/public/dist/**/*.*
+!/public/dist/.gitkeep
/public/workshops.json
/public/uploads
/public/blog
.env
/var
-/log
\ No newline at end of file
+/log
+
+/assets/.vite-ssg-temp/
\ No newline at end of file
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 00000000..df8b0a6c
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,15 @@
+in(__DIR__ . '/src')
+ ->in(__DIR__ . '/test')
+ ->in(__DIR__ . '/app')
+;
+
+return (new PhpCsFixer\Config())
+ ->setRules([
+ '@PER-CS2.0' => true,
+ 'declare_strict_types' => true,
+ 'no_unused_imports' => true,
+ ])
+ ->setFinder($finder);
\ No newline at end of file
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 00000000..24f1a242
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://json.schemastore.org/prettierrc",
+ "semi": false,
+ "tabWidth": 4,
+ "singleQuote": true,
+ "printWidth": 100,
+ "trailingComma": "none"
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 5fbe0259..4bbb4fc3 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
## Install
-You will need `composer`, `gulp` and `docker`.
+You will need `composer`, `node` and `docker`.
```shell
-composer install
npm install
+npm run build
cp .env.dist .env
docker-compose build
```
@@ -12,6 +12,7 @@ docker-compose build
## Run
```shell
docker-compose up -d
+docker compose exec php composer install
```
### Create DB Scheme
@@ -19,6 +20,11 @@ docker-compose up -d
docker compose exec php composer app:db:update
```
+### Import DB
+```shell
+docker-compose exec -T db mysql -uroot phpschool -proot < phpschool.sql
+```
+
### Generate Blog
```shell
docker compose exec php composer app:gen:blog
@@ -26,20 +32,42 @@ docker compose exec php composer app:gen:blog
Then navigate to `http://localhost` !
-Pages are cached on first view.
-If you need to clear the cache, run `docker compose exec php composer app:cc`.
+## Build CSS & JS
-## Build CSS
+This needs to be done for the main website (non cloud) to run in development mode.
```shell
-gulp sass
+npm run build
```
-## Build SVG's
+## Building CSS & JS for cloud dev
+
+The cloud styles and JS are built using `vite.js` and therefore has a dev/watcher mode with hot/live reloading.
+
+Run:
+
```shell
-gulp svg
+npm run dev
```
+You will also need to symlink the image directory:
+
+```shell
+ln -s ../../assets/img/cloud public/img/cloud
+````
+
+## For GitHub login
+
+Add `127.0.0.1 www.phpschool.local` to `/etc/hosts`
+
+Create a GitHub oauth App:
+
+Application Name: PHP School Local
+Homepage: http://www.phpschool.local
+Authorization Callback URL: http://www.phpschool.local/student-login:
+
+Take the client secret and client ID and place them in your `.env` file under the keys: `GITHUB_CLIENT_ID` & `GITHUB_CLIENT_SECRET`.
+
### View cache keys
```shell
@@ -49,7 +77,7 @@ docker-compose exec redis redis-cli keys '*'
### Clear cache
```shell
-docker compose exec php composer app:cc
+docker-compose exec php composer app:cc
```
## Deploy
diff --git a/app/config.php b/app/config.php
index ff0f1e8f..10054382 100644
--- a/app/config.php
+++ b/app/config.php
@@ -1,105 +1,149 @@
factory(function (DI\Container $c): Silly\Edition\PhpDi\Application {
$app = new Silly\Edition\PhpDi\Application('PHP School Website', 'UNKNOWN', $c);
$app->command('clear-cache', ClearCache::class);
- $app->command('create-user name email password', CreateUser::class);
+ $app->command('create-admin-user name email password', CreateAdminUser::class);
$app->command('generate-blog', GenerateBlog::class);
+ $app->command('download-composer-packages', DownloadComposerPackageList::class);
+ $app->command('sync-contributors', SyncContributors::class);
ConsoleRunner::addCommands($app, new SingleManagerProvider($c->get(EntityManagerInterface::class)));
return $app;
}),
+ 'basePath' => __DIR__ . '/../',
'app' => factory(function (ContainerInterface $c): App {
$app = Bridge::create($c);
$app->addRoutingMiddleware();
- $app->add($c->get(FpcCache::class));
+
+ $app->add(function (Request $request, RequestHandler $handler) use ($c): Response {
+ /** @var Session $session */
+ $session = $this->get(Session::class);
+
+ $student = $session->get('student');
+
+ $request = $request->withAttribute('student', $student);
+
+ return $handler->handle($request)
+ ->withHeader('cache-control', 'no-cache');
+ });
+ $app->add(StudentRefresher::class);
$app->add(new SessionMiddleware(['name' => 'phpschool']));
return $app;
}),
- FpcCache::class => factory(function (ContainerInterface $c): FpcCache {
- return new FpcCache($c->get('cache.fpc'));
- }),
- 'cache.fpc' => factory(function (ContainerInterface $c): CacheInterface {
- if (!$c->get('config')['enablePageCache']) {
- return new NullAdapter;
- }
- return new RedisAdapter(new Predis\Client(['host' => $c->get('config')['redisHost']]), 'fpc');
- }),
'cache' => factory(function (ContainerInterface $c): CacheInterface {
if (!$c->get('config')['enableCache']) {
- return new NullAdapter;
+ return new NullAdapter();
}
$redisConnection = new \Predis\Client(['host' => $c->get('config')['redisHost']]);
@@ -117,35 +161,18 @@
return new RedisAdapter($redisConnection, 'default');
}),
- PhpRenderer::class => factory(function (ContainerInterface $c): PhpRenderer {
- $settings = $c->get('config')['renderer'];
- $renderer = new PhpRenderer(
- $settings['template_path'],
- [
- 'links' => $c->get('config')['links'],
- ]
- );
-
- //default CSS
- $renderer->appendLocalCss('main-css', __DIR__ . '/../public/css/core.css');
- $renderer->appendRemoteCss('font', 'https://fonts.googleapis.com/css?family=Open+Sans: 400,700');
-
- //default JS
- $renderer->addJs('jquery', '//code.jquery.com/jquery-1.12.0.min.js');
- $renderer->addJs('main-js', '/js/main.min.js');
-
- return $renderer;
- }),
- LoggerInterface::class => factory(function (ContainerInterface $c): LoggerInterface{
+ LoggerInterface::class => factory(function (ContainerInterface $c): LoggerInterface {
$settings = $c->get('config')['logger'];
$logger = new Logger($settings['name']);
- $logger->pushProcessor(new UidProcessor);
+ $logger->pushProcessor(new UidProcessor());
$logger->pushHandler(new StreamHandler($settings['path'], Logger::DEBUG));
return $logger;
}),
+ SessionStorageInterface::class => get(Session::class),
+
Session::class => function (ContainerInterface $c): Session {
- return new Session;
+ return new Session();
},
FormHandlerFactory::class => function (ContainerInterface $c): FormHandlerFactory {
@@ -154,55 +181,20 @@
//commands
ClearCache::class => factory(function (ContainerInterface $c): ClearCache {
- return new ClearCache($c->get('cache.fpc'));
+ return new ClearCache($c->get('cache'));
}),
- CreateUser::class => factory(function (ContainerInterface $c): CreateUser {
- return new CreateUser($c->get(EntityManagerInterface::class));
+ CreateAdminUser::class => factory(function (ContainerInterface $c): CreateAdminUser {
+ return new CreateAdminUser($c->get(EntityManagerInterface::class));
}),
GenerateBlog::class => function (ContainerInterface $c): GenerateBlog {
return new GenerateBlog($c->get(Generator::class));
},
-
- Documentation::class => \DI\factory(function (ContainerInterface $c): Documentation {
- $tutorialGroup = new DocumentationGroup('tutorial', 'Workshop Tutorial');
- $tutorialGroup->addSection('index', 'Workshop Tutorial', 'docs/tutorial/index.phtml');
- $tutorialGroup->addSection('creating-your-own-workshop', 'Creating your own workshop', 'docs/tutorial/creating-your-own-workshop.phtml');
- $tutorialGroup->addSection('modify-theme', 'Modifying the theme of your workshop', 'docs/tutorial/modify-theme.phtml');
- $tutorialGroup->addSection('creating-an-exercise', 'Creating an exercise', 'docs/tutorial/creating-an-exercise.phtml');
-
- $referenceGroup = new DocumentationGroup('reference', 'Reference Documentation');
- $referenceGroup->addSection('index', 'Reference Documentation', 'docs/reference/index.phtml');
- $referenceGroup->addSection('container', 'The Container', 'docs/reference/container.phtml');
- $referenceGroup->addSection('available-services', 'Available Services', 'docs/reference/available-services.phtml');
- $referenceGroup->addSection('exercise-types', 'Exercise Types', 'docs/reference/exercise-types.phtml');
- $referenceGroup->addSection('exercise-solutions', 'Exercise Solutions', 'docs/reference/exercise-solutions.phtml');
- $referenceGroup->addSection('results', 'Results & Renderers', 'docs/reference/results.phtml');
- $referenceGroup->addSection('exercise-checks', 'Exercise Checks', 'docs/reference/exercise-checks.phtml');
- $referenceGroup->addSection('bundled-checks', 'Bundled Checks', 'docs/reference/bundled-checks.phtml');
- $referenceGroup->addSection('creating-simple-checks', 'Creating Simple Checks', 'docs/reference/creating-simple-checks.phtml');
- $referenceGroup->addSection('creating-custom-results', 'Creating Custom Results', 'docs/reference/creating-custom-results.phtml');
- $referenceGroup->addSection('creating-custom-result-renderers', 'Creating Custom Result Renderers', 'docs/reference/creating-custom-result-renderers.phtml');
- $referenceGroup->addSection('events', 'Events', 'docs/reference/events.phtml');
- $referenceGroup->addSection('creating-listener-checks', 'Creating Listener Checks', 'docs/reference/creating-listener-checks.phtml');
- $referenceGroup->addSection('self-checking-exercises', 'Self Checking Exercises', 'docs/reference/self-checking-exercises.phtml');
- $referenceGroup->addSection('exercise-events', 'Exercise Events', 'docs/reference/exercise-events.phtml');
- $referenceGroup->addSection('patching-exercise-solutions', 'Patching Exercise Submissions', 'docs/reference/patching-exercise-solutions.phtml');
-
-
- $indexGroup = new DocumentationGroup('index', 'Documentation Home');
- $indexGroup->addSection('index', 'Documentation Home', 'docs/index.phtml');
-
- $docs = new Documentation;
- $docs->addGroup($indexGroup);
- $docs->addGroup($tutorialGroup);
- $docs->addGroup($referenceGroup);
-
- return $docs;
- }),
-
- DocsAction::class => \DI\factory(function (ContainerInterface $c): DocsAction {
- return new DocsAction($c->get(PhpRenderer::class), $c->get(Documentation::class));
- }),
+ DownloadComposerPackageList::class => function (ContainerInterface $c): DownloadComposerPackageList {
+ return new DownloadComposerPackageList($c->get('guzzle.packagist'), $c->get(LoggerInterface::class));
+ },
+ SyncContributors::class => function (ContainerInterface $c): SyncContributors {
+ return new SyncContributors($c->get(Client::class), $c->get(LoggerInterface::class));
+ },
TrackDownloads::class => function (ContainerInterface $c): TrackDownloads {
return new TrackDownloads($c->get(WorkshopRepository::class), $c->get(WorkshopInstallRepository::class));
@@ -211,34 +203,61 @@
SubmitWorkshop::class => \DI\factory(function (ContainerInterface $c): SubmitWorkshop {
return new SubmitWorkshop(
$c->get(FormHandlerFactory::class)->create(
- new SubmitWorkshopInputFilter(new Client, $c->get(WorkshopRepository::class))
+ new SubmitWorkshopInputFilter(new Client(), $c->get(WorkshopRepository::class))
),
- new WorkshopCreator(new WorkshopComposerJsonInputFilter, $c->get(WorkshopRepository::class)),
+ new WorkshopCreator(new WorkshopComposerJsonInputFilter(), $c->get(WorkshopRepository::class)),
$c->get(EmailNotifier::class),
$c->get(LoggerInterface::class)
);
}),
+ SlackInvite::class => function (ContainerInterface $c): SlackInvite {
+ return new SlackInvite(
+ $c->get('guzzle'),
+ $c->get('config')['slackInviteApiToken']
+ );
+ },
+
+ Client::class => function (ContainerInterface $c): Client {
+ $client = new Client();
+ $client->authenticate($c->get('config')['github']['token'], AuthMethod::ACCESS_TOKEN);
+
+ return $client;
+ },
+
+ Github::class => function (ContainerInterface $c): Github {
+ return new Github([
+ 'clientId' => $c->get('config')['github']['clientId'],
+ 'clientSecret' => $c->get('config')['github']['clientSecret'],
+ ]);
+ },
+
+ StudentLogin::class => function (ContainerInterface $c): StudentLogin {
+ return new StudentLogin(
+ $c->get(Github::class),
+ $c->get(Session::class),
+ $c->get(EntityManagerInterface::class)
+ );
+ },
+
//admin
Login::class => \DI\factory(function (ContainerInterface $c): Login {
return new Login(
- $c->get(AuthenticationService::class),
- $c->get(FormHandlerFactory::class)->create(new LoginInputFilter),
- $c->get(PhpRenderer::class)
+ $c->get(AdminAuthenticationService::class),
+ $c->get(FormHandlerFactory::class)->create(new LoginInputFilter()),
+ $c->get('config')['jwtSecret']
);
}),
ClearCacheAction::class => function (ContainerInterface $c): ClearCacheAction {
return new ClearCacheAction(
- $c->get('cache.fpc'),
- $c->get(Messages::class)
+ $c->get('cache'),
);
},
Requests::class => \DI\factory(function (ContainerInterface $c): Requests {
return new Requests(
$c->get(WorkshopRepository::class),
- $c->get(PhpRenderer::class)
);
}),
@@ -246,7 +265,6 @@
return new All(
$c->get(WorkshopRepository::class),
$c->get(WorkshopInstallRepository::class),
- $c->get(PhpRenderer::class)
);
}),
@@ -254,8 +272,7 @@
return new Approve(
$c->get(WorkshopRepository::class),
$c->get(WorkshopFeed::class),
- $c->get('cache.fpc'),
- $c->get(Messages::class),
+ $c->get('cache'),
$c->get(EmailNotifier::class),
$c->get(LoggerInterface::class)
);
@@ -265,8 +282,7 @@
return new Promote(
$c->get(WorkshopRepository::class),
$c->get(WorkshopFeed::class),
- $c->get('cache.fpc'),
- $c->get(Messages::class)
+ $c->get('cache'),
);
}),
@@ -275,8 +291,7 @@
$c->get(WorkshopRepository::class),
$c->get(WorkshopInstallRepository::class),
$c->get(WorkshopFeed::class),
- $c->get('cache.fpc'),
- $c->get(Messages::class)
+ $c->get('cache'),
);
}),
@@ -284,24 +299,97 @@
return new View(
$c->get(WorkshopRepository::class),
$c->get(WorkshopInstallRepository::class),
- $c->get(PhpRenderer::class)
);
},
+ 'guzzle' => function (ContainerInterface $c): \GuzzleHttp\Client {
+ return new \GuzzleHttp\Client();
+ },
+
+ 'guzzle.packagist' => function (ContainerInterface $c) {
+ return new \GuzzleHttp\Client(['headers' => ['User-Agent' => 'PHP School: phpschool.team@gmail.com']]);
+ },
+
+ ComposerPackageAdd::class => function (ContainerInterface $c): ComposerPackageAdd {
+ return new ComposerPackageAdd(
+ new PackagistLatestVersion($c->get('guzzle.packagist')),
+ );
+ },
+
+ CurrentContext::class => function (): CurrentContext {
+ return CurrentContext::cloud();
+ },
+
+ //cloud
+ MarkdownConverterInterface::class => function (ContainerInterface $c): MarkdownConverterInterface {
+ $environment = new \League\CommonMark\Environment([
+ 'external_link' => [
+ 'internal_hosts' => 'www.phpschool.io',
+ 'open_in_new_window' => true,
+ 'nofollow' => '',
+ 'noopener' => 'external',
+ 'noreferrer' => 'external',
+ ],
+ ]);
+
+ $environment->addExtension(new CommonMarkCoreExtension());
+ $environment->addExtension(new GithubFlavoredMarkdownExtension());
+ $environment->addExtension(new ExternalLinkExtension());
+
+ $environment
+ ->addExtension(new ProblemFileExtension(
+ $c->get(ContextSpecificRenderer::class),
+ [
+ new AppName(),
+ new DocumentationShorthand(),
+ new Run(),
+ new Verify(),
+ $c->get(Context::class)
+ ]
+ ));
+
+ return new MarkdownConverter($environment);
+ },
+
+ ProblemFileConverter::class => function (ContainerInterface $c): ProblemFileConverter {
+ return new ProblemFileConverter($c->get(MarkdownConverterInterface::class));
+ },
+
+ RunExercise::class => function (ContainerInterface $c): RunExercise {
+ return new RunExercise(
+ $c->get(CloudWorkshopRepository::class),
+ $c->get(ProjectUploader::class),
+ $c->get(StudentWorkshopState::class),
+ $c->get(SessionStorageInterface::class),
+ );
+ },
+
+ VerifyExercise::class => function (ContainerInterface $c): VerifyExercise {
+ return new VerifyExercise(
+ $c->get(CloudWorkshopRepository::class),
+ $c->get(ProjectUploader::class),
+ $c->get(SessionStorageInterface::class),
+ $c->get(StudentWorkshopState::class),
+ new VueResultsRenderer()
+ );
+ },
+
+ ProjectUploader::class => function (ContainerInterface $c): ProjectUploader {
+ return new ProjectUploader(new PathGenerator());
+ },
+
'form.event' => function (ContainerInterface $c): FormHandler {
- return $c->get(FormHandlerFactory::class)->create(new EventInputFilter);
+ return $c->get(FormHandlerFactory::class)->create(new EventInputFilter());
},
EventAll::class => function (ContainerInterface $c): EventAll {
- return new EventAll($c->get(EventRepository::class), $c->get(PhpRenderer::class));
+ return new EventAll($c->get(EventRepository::class));
},
EventCreate::class => function (ContainerInterface $c): EventCreate {
return new EventCreate(
$c->get(EventRepository::class),
$c->get('form.event'),
- $c->get(PhpRenderer::class),
- $c->get(Messages::class)
);
},
@@ -309,23 +397,16 @@
return new EventUpdate(
$c->get(EventRepository::class),
$c->get('form.event'),
- $c->get(PhpRenderer::class),
- $c->get(Messages::class)
);
},
EventDelete::class => function (ContainerInterface $c): EventDelete {
return new EventDelete(
$c->get(EventRepository::class),
- $c->get('cache.fpc'),
- $c->get(Messages::class)
+ $c->get('cache'),
);
},
- Messages::class => \DI\factory(function (ContainerInterface $c): Messages {
- return new Messages();
- }),
-
WorkshopFeed::class => \DI\factory(function (ContainerInterface $c): WorkshopFeed {
return new WorkshopFeed(
$c->get(WorkshopRepository::class),
@@ -345,16 +426,28 @@
return $c->get(EntityManagerInterface::class)->getRepository(Event::class);
},
- AuthenticationService::class => \DI\factory(function (ContainerInterface $c): AuthenticationService {
- $authService = new \Laminas\Authentication\AuthenticationService;
- $authService->setAdapter(new Doctrine($c->get(EntityManagerInterface::class)));
- return new AuthenticationService($authService);
- }),
+ DoctrineORMBlogRepository::class => function (ContainerInterface $c): DoctrineORMBlogRepository {
+ return $c->get(EntityManagerInterface::class)->getRepository(BlogPost::class);
+ },
+
+ StudentRepository::class => function (ContainerInterface $c): StudentRepository {
+ return $c->get(EntityManagerInterface::class)->getRepository(Student::class);
+ },
- Authenticator::class => \DI\factory(function (ContainerInterface $c): Authenticator {
- return new Authenticator($c->get(AuthenticationService::class));
+ AdminAuthenticationService::class => \DI\factory(function (ContainerInterface $c): AdminAuthenticationService {
+ $authService = new \Laminas\Authentication\AuthenticationService(
+ new \Laminas\Authentication\Storage\NonPersistent(),
+ new Doctrine($c->get(EntityManagerInterface::class))
+ );
+ return new AdminAuthenticationService($authService);
}),
+ StudentAuthenticator::class => function (ContainerInterface $c): StudentAuthenticator {
+ return new StudentAuthenticator(
+ $c->get(Session::class),
+ );
+ },
+
ORMSetup::class => \DI\factory(function (ContainerInterface $c): Configuration {
$doctrineConfig = $c->get('config')['doctrine'];
@@ -375,8 +468,13 @@
EntityManagerInterface::class => \DI\factory(function (ContainerInterface $c): EntityManagerInterface {
Type::addType('uuid', UuidType::class);
- return EntityManager::create(
+
+ $driver = \Doctrine\DBAL\DriverManager::getConnection(
$c->get('config')['doctrine']['connection'],
+ );
+
+ return new EntityManager(
+ $driver,
$c->get(ORMSetup::class)
);
}),
@@ -390,13 +488,64 @@
Generator::class => function (ContainerInterface $c): Generator {
return new Generator(
- new Parser,
+ new Parser(null, new class () implements \Mni\FrontYAML\Markdown\MarkdownParser {
+ public function parse($markdown): string
+ {
+ return (new Parsedown())->parse($markdown);
+ }
+ }),
+ $c->get(DoctrineORMBlogRepository::class),
__DIR__ . '/../posts/',
- __DIR__ . '/../public/blog',
- $c->get(PhpRenderer::class)
);
},
+ CloudWorkshopRepository::class => function (ContainerInterface $c): CloudWorkshopRepository {
+ return new CloudWorkshopRepository($c->get(WorkshopRepository::class));
+ },
+
+ 'exerciseRunnerRateLimiterFactory' => function (ContainerInterface $c): RateLimiterFactory {
+ $redisConnection = new \Predis\Client(['host' => $c->get('config')['redisHost']]);
+ try {
+ $redisConnection->connect();
+ } catch (ConnectionException $e) {
+ throw new \RuntimeException(
+ sprintf(
+ 'Could not connect to redis using host: "%s". Message: "%s"',
+ $c->get('config')['redisHost'],
+ $e->getMessage()
+ )
+ );
+ }
+
+ $adapter = new RedisAdapter($redisConnection, 'rate_limiter');
+
+ return new RateLimiterFactory(
+ [
+ 'id' => 'exerciseRunner',
+ 'policy' => 'sliding_window',
+ 'limit' => 10,
+ 'interval' => '1 minute',
+ ],
+ new CacheStorage($adapter)
+ );
+ },
+
+ ExerciseRunnerRateLimiter::class => function (ContainerInterface $c): ExerciseRunnerRateLimiter {
+ return new ExerciseRunnerRateLimiter(
+ $c->get(SessionStorageInterface::class),
+ $c->get('exerciseRunnerRateLimiterFactory')
+ );
+ },
+
+ JwtAuthentication::class => function (ContainerInterface $c): JwtAuthentication {
+ return new JwtAuthentication([
+ 'secret' => $c->get('config')['jwtSecret'],
+ 'path' => '/api/admin',
+ "ignore" => ["/api/admin/login"],
+ "secure" => !$c->get('config')['devMode'],
+ ]);
+ },
+
'config' => [
'containerCacheDir' => __DIR__ . '/../var/container_cache',
@@ -420,9 +569,9 @@
'github-website' => 'https://github.com/php-school/phpschool.io',
],
- 'enablePageCache' => filter_var($_ENV['CACHE.FPC.ENABLE'], FILTER_VALIDATE_BOOLEAN),
'enableCache' => filter_var($_ENV['CACHE.ENABLE'], FILTER_VALIDATE_BOOLEAN),
'redisHost' => $_ENV['REDIS_HOST'],
+ 'devMode' => filter_var($_ENV['DEV_MODE'], FILTER_VALIDATE_BOOLEAN),
'doctrine' => [
'meta' => [
@@ -431,7 +580,7 @@
'src/User/Entity',
],
'auto_generate_proxies' => true,
- 'proxy_dir' => __DIR__.'/../cache/proxies',
+ 'proxy_dir' => __DIR__ . '/../cache/proxies',
],
'connection' => [
'driver' => 'pdo_mysql',
@@ -439,8 +588,18 @@
'dbname' => $_ENV['MYSQL_DATABASE'],
'user' => $_ENV['MYSQL_USER'],
'password' => $_ENV['MYSQL_PASSWORD'],
+ 'charset' => 'utf8mb4',
]
- ]
+ ],
+
+ 'github' => [
+ 'clientId' => $_ENV['GITHUB_CLIENT_ID'],
+ 'clientSecret' => $_ENV['GITHUB_CLIENT_SECRET'],
+ 'token' => $_ENV['GITHUB_TOKEN'],
+ ],
+
+ 'jwtSecret' => $_ENV['JWT_SECRET'],
+ 'slackInviteApiToken' => $_ENV['SLACK_INVITE_API_TOKEN'],
],
//slim settings
diff --git a/assets/app.js b/assets/app.js
new file mode 100644
index 00000000..1f1d65c1
--- /dev/null
+++ b/assets/app.js
@@ -0,0 +1,236 @@
+import "vite/modulepreload-polyfill";
+import "./styles";
+import { FocusTrap } from "focus-trap-vue";
+import VueClickAway from "vue3-click-away";
+import VueDiff from "vue-diff";
+import "vue-diff/dist/index.css";
+import { markRaw } from "vue";
+import VueShepherdPlugin from "./shepherd-plugin";
+import results from "./components/Online/Results/results.js";
+import Home from "./components/Website/Pages/PageHome.vue";
+import SubmitWorkshop from "./components/Website/Pages/PageSubmitWorkshop.vue";
+
+import Offline from "./components/Website/Pages/PageOffline.vue";
+import Docs from "./components/Website/Pages/PageDocs.vue";
+
+import App from "./components/Website/App.vue";
+
+import { docs } from "./components/Website/Docs/contents.js";
+import MainLayout from "./components/Website/MainLayout.vue";
+import CompactLayout from "./components/Website/CompactLayout.vue";
+
+import Dashboard from "./components/Online/PageDashboard.vue";
+import Events from "./components/Website/Pages/PageEvents.vue";
+import BlogIndex from "./components/Website/Pages/PageBlog.vue";
+import BlogPost from "./components/Website/Pages/PageBlogPost.vue";
+import { ViteSSG } from "vite-ssg";
+import { createPinia } from "pinia";
+import { useBlogStore } from "./stores/blog";
+import { useEventStore } from "./stores/events";
+
+import { useAdminStore } from "./stores/admin";
+import AdminLayout from "./components/Admin/AdminLayout.vue";
+import EmptyAdminLayout from "./components/Admin/EmptyLayout.vue";
+import AdminLogin from "./components/Admin/PageLogin.vue";
+import AminHome from "./components/Admin/PageHome.vue";
+import AdminWorkshops from "./components/Admin/PageWorkshops.vue";
+import AdminWorkshop from "./components/Admin/PageWorkshop.vue";
+import AdminWorkshopInstalls from "./components/Admin/PageWorkshopInstalls.vue";
+import AdminNewWorkshops from "./components/Admin/PageNewWorkshops.vue";
+import AdminStudents from "./components/Admin/PageStudents.vue";
+import AdminSettings from "./components/Admin/PageSettings.vue";
+import AdminEvents from "./components/Admin/PageEvents.vue";
+import { useStudentStore } from "./stores/student";
+import { useWorkshopStore } from "./stores/workshops";
+import ExerciseEditor from "./components/Online/PageExerciseEditor.vue";
+
+const docRoutes = [].concat(
+ ...docs.map((doc) => {
+ return doc.sections.map((section) => {
+ const parts = ["docs", doc.path, section.path];
+
+ return {
+ path: "/" + parts.filter((part) => part !== "").join("/"),
+ component: section.component,
+ meta: { section: section, group: doc },
+ };
+ });
+ }),
+);
+
+const routes = [
+ { path: "/", component: Home, meta: { layout: MainLayout } },
+ { path: "/online/:workshop?", component: Dashboard, meta: { layout: CompactLayout } },
+ {
+ path: "/online/editor/:workshop/:exercise",
+ component: ExerciseEditor,
+ name: "editor",
+ props: true,
+ meta: { layout: CompactLayout },
+ },
+ { path: "/offline", component: Offline, meta: { layout: MainLayout } },
+ { path: "/submit", component: SubmitWorkshop, meta: { layout: MainLayout } },
+ {
+ path: "/docs",
+ component: Docs,
+ children: docRoutes,
+ meta: { layout: MainLayout },
+ },
+ {
+ path: "/events",
+ component: Events,
+ name: "events",
+ meta: { layout: MainLayout },
+ },
+ {
+ path: "/blog",
+ component: BlogIndex,
+ name: "blog",
+ meta: { layout: MainLayout },
+ },
+ {
+ path: "/blog/:page(\\d+)?",
+ component: BlogIndex,
+ props: true,
+ meta: { layout: MainLayout },
+ },
+ {
+ path: "/blog/:slug",
+ component: BlogPost,
+ name: "blog-post",
+ props: true,
+ meta: { layout: MainLayout },
+ },
+ {
+ path: "/login",
+ component: AdminLogin,
+ name: "admin-login",
+ props: true,
+ meta: { layout: EmptyAdminLayout },
+ },
+ {
+ path: "/admin",
+ component: AminHome,
+ name: "admin",
+ props: true,
+ meta: { layout: AdminLayout },
+ },
+ {
+ path: "/admin/workshops",
+ component: AdminWorkshops,
+ props: true,
+ meta: { layout: AdminLayout },
+ },
+ {
+ path: "/admin/workshop/:id",
+ component: AdminWorkshop,
+ props: true,
+ meta: { layout: AdminLayout },
+ },
+ {
+ path: "/admin/workshop-installs",
+ component: AdminWorkshopInstalls,
+ props: true,
+ meta: { layout: AdminLayout },
+ },
+ {
+ path: "/admin/new-workshops",
+ component: AdminNewWorkshops,
+ props: true,
+ meta: { layout: AdminLayout },
+ },
+ {
+ path: "/admin/students",
+ component: AdminStudents,
+ props: true,
+ meta: { layout: AdminLayout },
+ },
+ {
+ path: "/admin/settings",
+ component: AdminSettings,
+ props: true,
+ meta: { layout: AdminLayout },
+ },
+ {
+ path: "/admin/events",
+ component: AdminEvents,
+ props: true,
+ meta: { layout: AdminLayout },
+ },
+];
+
+export const createApp = ViteSSG(
+ App,
+ {
+ routes,
+ scrollBehavior() {
+ // always scroll to top
+ return { top: 0 };
+ },
+ },
+ async ({ app, router, isClient, initialState, onSSRAppRendered }) => {
+ Object.entries(results).forEach(([name, resultComponent]) => {
+ app.component(name, resultComponent);
+ });
+ app.component("FocusTrap", FocusTrap);
+
+ app.use(VueClickAway);
+ app.use(VueDiff);
+ app.use(VueShepherdPlugin);
+ const pinia = createPinia();
+ pinia.use(({ store }) => {
+ store.router = markRaw(router);
+ });
+ app.use(pinia);
+
+ if (isClient) {
+ pinia.state.value = initialState.pinia || {};
+
+ const studentStore = useStudentStore(pinia);
+ await studentStore.initialize();
+ }
+
+ if (!isClient || import.meta.env.DEV) {
+ const blogStore = useBlogStore(pinia);
+ await blogStore.initialize();
+
+ const eventStore = useEventStore(pinia);
+ await eventStore.initialize();
+
+ const workshopStore = useWorkshopStore(pinia);
+ await workshopStore.initialize();
+
+ onSSRAppRendered(() => {
+ initialState.pinia = pinia.state.value;
+ });
+ }
+
+ const adminStore = useAdminStore(pinia);
+
+ router.beforeEach(async (to) => {
+ //if not admin route and not login route, skip, it's a non-authenticated route
+ if (!to.path.startsWith("/admin") && to.name !== "admin-login") {
+ return;
+ }
+
+ //if we are not logged in redirect to login page
+ if (!adminStore.admin && to.name !== "admin-login") {
+ return { name: "admin-login" };
+ }
+ });
+ },
+);
+
+export async function includedRoutes(paths, routes) {
+ let pathsToRender = routes.filter((route) => {
+ return route.name === "blog" || route.name === "blog-post" || route.name === "events";
+ });
+
+ const response = await fetch(import.meta.env.VITE_API_URL + "/api/posts");
+ const posts = await response.json();
+ const slugs = posts.posts.map((post) => post.slug);
+
+ return pathsToRender.flatMap((route) => {
+ return route.name === "blog-post" ? slugs.map((slug) => `/blog/${slug}`) : route.path;
+ });
+}
diff --git a/assets/components/Admin/AdminLayout.vue b/assets/components/Admin/AdminLayout.vue
new file mode 100644
index 00000000..7a260e12
--- /dev/null
+++ b/assets/components/Admin/AdminLayout.vue
@@ -0,0 +1,266 @@
+
+
+
+ {{ event.date_formatted }} {{ event.venue }}
+ {{ event.description }}
+ {{ workshop.submitter_email }} {{ workshop.description }} Clear the full page and Redis cache. This includes Doctrine and HTML cache.
+ {{ student.name }}
+
+ {{ student.email }}
+ {{ student.location }}
+ Last seen
+
+ Online {{ workshop.submitter_email }} {{ workshop.installs }} downloads {{ dep.name }} {{ dep.version }} You currently have no dependencies. {{ message }}
+ Congratulations! your solution passed.
+ Congratulations! your solution passed.
+ {{ studentStore.totalCompleted() }}/{{ workshopStore.totalExercises }} Completed Output was incorrect
+ Your programs output did not match the expected output:
+
+ Your programs headers did not match the expected headers:
+ Output was incorrect
+ Your programs output did not match the expected output.
+ Your program was executed like so:
+ Your program was executed with no command line arguments.
+ Output was incorrect
+ Your programs output did not match the expected output.
+ Composer requirements were not fulfilled
+ Your program was required to use various composer packages.
+
+ The file
+ Some packages were not required which should be used in this exercise: {{ data.reason }}
+ File content was incorrect for
+
+
+ The content of the file
+ Functions requirements were not fulfilled
+ Your program was required to use certain functions and was prohibited from using others.
+ Some functions were used which should not be used in this exercise: Some functions were missing. You should use the following functions:
+ Your program was executed with no command line arguments.
+
+ {{ message }}
+
+ {{ studentStore.totalCompleted() }} out of
+ {{ workshopStore.totalExercises }}
+ {{ studentStore.student.name }} {{ totalCompleted }} out of {{ workshopStore.totalExercises }}
+ {{ title }}
+ Event Class:
+ Arguments: Arguments - None Welcome to the PHP School documentation - The documentation is spit up in to several sections: Learn how to setup and build a workshop step by step! Learn about the individual components of the workshop such as the available checks, exercise types, solutions, services and so on.
+ Already built a workshop? Go forth and
+
+ There are numerous services exposed via the DI container. The services exposed to user-land code are listed below. These can be used in your exercises, custom-checks and other services you may
+ create as part of your workshop.
+
+ An instance of
+ Colors\Color
+ . Use this to apply ANSI styles to strings. For example, to colour a string ready to be printed to the console.
+ A utility class to print content to the standard output stream. Currently this is the standard out connected to the terminal. This is a repository of exercises allowing to retrieve exercises and get information regarding them. This is a repository of exercises allowing to retrieve exercises and get information regarding them. EventDispatcher allows listening to events and inserting verifiers at certain points throughout the verifying
+ An instance of
+ Symfony\Component\Filesystem\Filesystem
+ allowing to perform simple IO operations.
+
+ An instance of
+ PhpParser\Parser
+ which allows to parse PHP code in to an AST structure.
+ Utility to apply patches to PHP code from Exercises.
+ An implementation of
+ PhpSchool\CliMenu\Terminal\TerminalInterface
+ which allows to retrieve and control the current terminal. For example getting the current terminal width.
+ A utility to render Markdown to a string which should display nicely in a console terminal using ANSI escape sequences to format the output. This object provides access to the list of the exercises the student has completed and the exercise they are currently studying.
+ An instance of
+ PhpSchool\PSX\SyntaxHighlighter
+ which highlights PHP code using ANSI escape sequences ready to output on a console terminal.
+ Add your own renderer for your custom results or replace an existing renderer, with your own implementation.
+ This article documents each of the checks bundled with the workshop framework and how to use them. Remember the exercise types from the
+ Well, checks can support one or more of these exercise types types. So inspect the table below to see if the check you want to use actually supports your exercise type.
+ Here is an example of how to force the student to use the function
+ If a student submitted something like: The output would be something along the lines of:
+ Here is an example of how to force the student to require the
+
+ A number of failures can occur here, the student may not even have a
+
+ Here is an example where we require that the student insert a record in to the
+ If the student did not insert into the database, for example, if they submitted the following: Then they would receive the following failure: If they submitted a proper solution, like the following: They would receive this success:
+ After a student's solution has been verified, the result set is rendered to the console. The result set is made up of several individual results. Verification is deemed to have failed if any one
+ of those results is a failure. Each result represents a different thing, for example, each check will likely inject a result in to the result set. The output verification will be a single result,
+ the parsing of the file will be a single result, and so on.
+ Each Result class has an associated renderer, the renderers job is to take the information from the result and render in to the console.
+ Results are what
+
+ The result set is an instance of
+
+ So, how do we know if a result is a success or failure? Well there are two other interfaces, extending from
+ Both of these interfaces add no extra methods, they are purely for determining whether a result is considered a success or failure.
+ The interface for
+ This method should just return the name of the check associated with this result. This is used when rendering the result to the console.
+ There are default implementations for
+ As you saw from the interface, the only required piece of information is the check name. So construction would look like the following.
+ If you are within a Check (eg
+
+ The default implementation of
+
+ There are a number of
+
+ When you want to report information that is not simple a message, you will need to create your own result class. If you would build a check that verifies the contents of a database, you may want
+ to provide a list of missing records as an array instead of just a message. You would then write a renderer that may render each row as a new line with a bullet point preceding it. Learn how to
+ create your own checks
+
+ Each result renderer must implement the interface
+
+
+ When each renderer is created, it is passed the
+
+
+ The workshop framework picks a result renderer based on the mappings in
+
+ If you create a new implementation of
+
+ The whole process may sound complicated, however, this is not true. To summarise, your check should return a result. The result should be mapped to a renderer. The results are rendered by the
+ framework. It will pick the correct renderer based on the mapping.
+
+ In the next set of articles we will learn about and build a check. Once the check is complete, we will build a custom result and result renderer for it, you can jump
+ Events
+
+
+
+
+
+
+ {{ event.name }}
+ {{ event.name }}
+
+ Home
+ New Workshop Requests
+ No new workshop requests
+
+
+
+
+
+ {{ workshop.name }}
+ /
+ {{ workshop.code }}
+
+
+ Settings
+ Clear cache
+ All Students
+
+
+
diff --git a/assets/components/Admin/PageWorkshop.vue b/assets/components/Admin/PageWorkshop.vue
new file mode 100644
index 00000000..05cc8fe0
--- /dev/null
+++ b/assets/components/Admin/PageWorkshop.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+ {{ workshop.name }}
+
+
+
+ Workshops Installs for the last 30 days
+ All Workshops
+
+
+
+
+
+
+ Composer Dependencies
+
+
+
+ {{ title }}
+
+
+
+
+
+
+ Program output
+ Your program could not be executed, there was a syntax error
+
+
+
+ {{programRunResult.failure.reason}}
+
+
+
+ Official Solution
+ The problem...
+
+ {{ exercise.name }}
+
+ Output mismatch...
+ Your Output
+ Expected Output
+ Your Headers
+ Expected Headers
+ Run context
+ Request details
+ {{ currentFailure.request.method }}
+ {{ currentFailure.request.uri }}
+ Request headers
+
+ {{headers(currentFailure.request.headers)}}Request body
+
+ {{currentFailure.request.body}}Output mismatch...
+ Your Output
+ Expected Output
+ Run context
+ Command line arguments
+
+
+ {{ arg }}
+
+ php solution.php {{currentFailure.args.join(' ')}}Output mismatch...
+ Your Output
+ Expected Output
+ Composer requirements failure...
+ Missing Composer configuration
+ {{ data.missing_component }}
+ is missing. You must specify some requirements for the file to be created.
+ Missing packages
+
+ {{ composerPackage }}
+
+ {{ data.file_name }}
+ File content mismatch...
+ {{ data.file_name }}
+ your program was required to create, did not match the expected content.
+
+ Your Content
+ Expected Content
+ Function requirements failure...
+ Banned functions
+ {{ call.function }} on line {{ call.line }}
+ Missing functions
+
+ {{ func }}
+
+
+
+
diff --git a/assets/components/Online/Results/results.js b/assets/components/Online/Results/results.js
new file mode 100644
index 00000000..43667de9
--- /dev/null
+++ b/assets/components/Online/Results/results.js
@@ -0,0 +1,21 @@
+import FailureResult from "./FailureResult.vue";
+import FunctionRequirementsFailureResult from "./FunctionRequirementsFailureResult.vue";
+import ComposerFailureResult from "./ComposerFailureResult.vue";
+import ComparisonFailureResult from "./ComparisonFailureResult.vue";
+import FileComparisonFailureResult from "./FileComparisonFailureResult.vue";
+import CliRunResult from "./CliRunResult.vue";
+import CgiRunResult from "./CgiRunResult.vue";
+import CliOutputMismatchResult from "./CliOutputMismatchResult.vue";
+import CgiOutputMismatchResult from "./CgiOutputMismatchResult.vue";
+
+export default {
+ FailureResult,
+ FunctionRequirementsFailureResult,
+ ComposerFailureResult,
+ ComparisonFailureResult,
+ FileComparisonFailureResult,
+ CliRunResult,
+ CgiRunResult,
+ CliOutputMismatchResult,
+ CgiOutputMismatchResult,
+};
diff --git a/assets/components/Online/RunResult.vue b/assets/components/Online/RunResult.vue
new file mode 100644
index 00000000..a8c826d1
--- /dev/null
+++ b/assets/components/Online/RunResult.vue
@@ -0,0 +1,97 @@
+
+
+
+ Command line arguments
+
+
+ {{ arg }}
+ Request details
+ {{ run.request.method }}
+ {{ run.request.uri }}
+ Request headers
+
+ {{headers(run.request.headers)}}Request body
+
+ {{run.request.body}}Output
+
+
+
+ {{run.output}}
+
+
+
+
+
+ Select a workshop
+
+
+ {{ check }}
+
+
+
+ {{ interfaceToImplement }}
+ {{ type }}
+ {{ compatible.join(", ") }}
+
+
+
+
+
+
+
+
+
+
+
+ #
+
+
+
diff --git a/assets/components/Website/Docs/DocCode.vue b/assets/components/Website/Docs/DocCode.vue
new file mode 100644
index 00000000..196595ec
--- /dev/null
+++ b/assets/components/Website/Docs/DocCode.vue
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/components/Website/Docs/DocList.vue b/assets/components/Website/Docs/DocList.vue
new file mode 100644
index 00000000..dea16e43
--- /dev/null
+++ b/assets/components/Website/Docs/DocList.vue
@@ -0,0 +1,16 @@
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+ {{ col }}
+
+
+ {{ line }}
+
+
+ {{ line }}
+
+
+ {{ title }}
+ #
+
+
+
diff --git a/assets/components/Website/Docs/EventDescription.vue b/assets/components/Website/Docs/EventDescription.vue
new file mode 100644
index 00000000..4c10eac6
--- /dev/null
+++ b/assets/components/Website/Docs/EventDescription.vue
@@ -0,0 +1,34 @@
+
+
+
+ {{ event }}
+ {{ eventClass }}
+
+
+
+ {{ arg.type }}
+
+
+
diff --git a/assets/components/Website/Docs/ResultRendererMappings.vue b/assets/components/Website/Docs/ResultRendererMappings.vue
new file mode 100644
index 00000000..9dbf2dd9
--- /dev/null
+++ b/assets/components/Website/Docs/ResultRendererMappings.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Type
+
+
+
+
+ Interface
+
+
+
+
+ Methods to implement
+
+
+
+
+
diff --git a/assets/components/Website/Docs/Sections/DocHome.vue b/assets/components/Website/Docs/Sections/DocHome.vue
new file mode 100644
index 00000000..3e0e567e
--- /dev/null
+++ b/assets/components/Website/Docs/Sections/DocHome.vue
@@ -0,0 +1,23 @@
+
+
+
+ {{ result[0] }}
+
+
+
+
+
+
+
diff --git a/assets/components/Website/Docs/Sections/Reference/BundledChecks.vue b/assets/components/Website/Docs/Sections/Reference/BundledChecks.vue
new file mode 100644
index 00000000..f0d2b8fb
--- /dev/null
+++ b/assets/components/Website/Docs/Sections/Reference/BundledChecks.vue
@@ -0,0 +1,264 @@
+
+
+ Colors\Color
+ PhpSchool\PhpWorkshop\Output\OutputInterface
+ PhpSchool\PhpWorkshop\ExerciseRepository
+ PhpSchool\PhpWorkshop\ExerciseRepository
+ PhpSchool\PhpWorkshop\Event\EventDispatcher
+ Symfony\Component\Filesystem\Filesystem
+ PhpParser\Parser
+ PhpSchool\PhpWorkshop\CodePatcher
+ PhpSchool\CliMenu\Terminal\TerminalInterface
+ PhpSchool\PhpWorkshop\MarkdownRenderer
+ PhpSchool\PhpWorkshop\UserState
+ PhpSchool\PSX\SyntaxHighlighter
+ PhpSchool\PhpWorkshop\Factory\ResultRendererFactory
+
+
+
+ php -l <submission-file>
+ composer.lock
+ files exists and contains entries for the required packages.
+ PDO
+ object. It prepends the database DSN as a CLI argument to the student's solution so they can connect to the database. The
+ PDO
+ object is passed to the exercise before and after the student's solution has been executed, allowing you to first seed the database and then verify the contents of the database.
+ curl_exec
+ and ban the usage of
+ file_get_contents
+ . This could be useful if you wanted to teach advanced configuration of data transfers.
+
+class MyExercise extends AbstractExercise
+ implements ExerciseInterface, FunctionRequirementsExerciseCheck
+{
+ ...snip
+
+ /**
+ * @param ExerciseDispatcher $dispatcher
+ */
+ public function configure(ExerciseDispatcher $dispatcher)
+ {
+ $dispatcher->requireCheck(FunctionRequirementsCheck::class);
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getRequiredFunctions()
+ {
+ return ['curl_exec'];
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getBannedFunctions()
+ {
+ return ['file_get_contents'];
+ }
+}
+
+
+<?php
+
+echo file_get_contents('http://example.com');
+
+
+
+
+ nikic/fast-route
+ package via Composer. This is useful if you want to focus on a specific problem or promote popular/battle tested packages.
+
+class MyExercise extends AbstractExercise
+ implements ExerciseInterface, ComposerExerciseCheck
+{
+ ...snip
+
+ /**
+ * @param ExerciseDispatcher $dispatcher
+ */
+ public function configure(ExerciseDispatcher $dispatcher)
+ {
+ $dispatcher->requireCheck(ComposerCheck::class);
+ }
+
+ /**
+ * @return array
+ */
+ public function getRequiredPackages()
+ {
+ return [
+ 'nikic/fast-route'
+ ];
+ }
+}
+
+ composer.json
+ or a
+ composer.lock
+ file. If this is the case, the check will fail and a message will be printed. If the required files are present, but the package has not been found, the output will look like the following:
+
+
+
+ users
+ table which we will create in the
+ seed
+ method of our check. We will then verify that there are some records in the
+ users
+ table.
+
+class MyExercise extends AbstractExercise
+ implements ExerciseInterface, DatabaseExerciseCheck
+{
+ ...snip
+
+ /**
+ * @param ExerciseDispatcher $dispatcher
+ */
+ public function configure(ExerciseDispatcher $dispatcher)
+ {
+ $dispatcher->requireCheck(DatabaseCheck::class);
+ }
+
+ /**
+ * @param PDO $db
+ * @return void
+ */
+ public function seed(PDO $db)
+ {
+ $db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER, gender TEXT)');
+ }
+
+ /**
+ * @param PDO $db
+ * @return bool
+ */
+ public function verify(PDO $db)
+ {
+ return $db->query('select COUNT(*) from users')->fetchColumn() > 0;
+ }
+}
+
+
+<?php
+
+echo "Where is the database?";
+
+
+
+
+<?php
+
+$dsn = $argv[1];
+$db = new PDO($dsn);
+$stmt = $db->prepare('INSERT INTO users (name, age, gender) VALUES (:name, :age, :gender)');
+$stmt->execute([':name' => 'Karl Renner', ':age' => 80, ':gender' => 'Male']);
+
+
+
+
+
diff --git a/assets/components/Website/Docs/Sections/Reference/CheckResults.vue b/assets/components/Website/Docs/Sections/Reference/CheckResults.vue
new file mode 100644
index 00000000..f23ccd2e
--- /dev/null
+++ b/assets/components/Website/Docs/Sections/Reference/CheckResults.vue
@@ -0,0 +1,294 @@
+
+
+ PhpSchool\PhpWorkshop\ResultAggregator
+ and results are added to it with
+ add(ResultInterface $result)
+ . Note the interface
+ PhpSchool\PhpWorkshop\Result\ResultInterface
+ . Every result must implement this interface.
+ ResultInterface
+ , which are:
+
+
+
+ PhpSchool\PhpWorkshop\Result\SuccessInterfacePhpSchool\PhpWorkshop\Result\FailureInterfaceResultInterface
+ is very simple:
+
+<?php
+
+interface ResultInterface
+{
+ /**
+ * @return string
+ */
+ public function getCheckName();
+}
+ SuccessInterface
+ &
+ FailureInterface
+ for you to use in your checks. If you need something more bespoke to render the failure of your check, you should
+
+<?php
+
+use PhpSchool\PhpWorkshop\Result\Success;
+
+$success = new Success('My Check');
+
+ $this
+ refers to
+ PhpSchool\PhpWorkshop\Check\CheckInterface
+ ) then you can use the static constructor
+ fromCheck(CheckInterface $check)
+ which just pulls the check name from the actual check for convenience.
+
+<?php
+
+namespace PhpSchool\PhpWorkshop\Check;
+
+use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface;
+use PhpSchool\PhpWorkshop\Result\Success;
+
+class MyCheck implements SimpleCheckInterface
+{
+ ...snip
+
+ public function check(ExerciseInterface $exercise, $fileName)
+ {
+ return Success::fromCheck($this);
+ }
+}
+
+ FailureInterface
+ needs one more piece of information other than the check name: the reason for the failure. Construction is fairly similar:
+
+<?php
+
+use PhpSchool\PhpWorkshop\Result\Failure;
+
+//constructor
+$failure = new Failure('My Check', 'Something went wrong!');
+
+//static constructor
+$failure = Failure::fromNameAndReason('My Check', 'Something went wrong!');
+
+//static constructor with check
+$myCheck = new MyCheck;
+$failure = Failure::fromCheckAndReason($check, 'Something went wrong!');
+
+ FailureInterface
+ implementations bundled with the framework for some of the other checks:
+
+
+
+ PhpSchool\PhpWorkshop\Result\Cgi\GenericFailurePhpSchool\PhpWorkshop\Result\Cgi\RequestFailurePhpSchool\PhpWorkshop\Result\Cli\GenericFailurePhpSchool\PhpWorkshop\Result\Cli\RequestFailurePhpSchool\PhpWorkshop\Result\FunctionRequirementsFailure
+ PhpSchool\PhpWorkshop\Result\ComparisonFailurePhpSchool\PhpWorkshop\ResultRenderer\ResultRendererInterface
+ which looks like below.
+
+<?php
+
+namespace PhpSchool\PhpWorkshop\ResultRenderer;
+
+use PhpSchool\PhpWorkshop\Result\ResultInterface;
+
+interface ResultRendererInterface
+{
+
+ /**
+ * @param ResultsRenderer $renderer
+ * @return string
+ */
+ public function render(ResultsRenderer $renderer);
+}
+
+ PhpSchool\PhpWorkshop\ResultAggregator
+ is rendered by
+ PhpSchool\PhpWorkshop\ResultRenderer\ResultsRenderer
+ . It loops each result, passing the result to
+ PhpSchool\PhpWorkshop\Factory\ResultRendererFactory
+ which returns the correct renderer. The
+ render
+ method is called and then the output of each is written to the console.
+ ResultInterface
+ as the first constructor argument.
+ render
+ is called with an instance of
+ PhpSchool\PhpWorkshop\ResultRenderer\ResultsRenderer
+ and it should return a string representation of the
+ ResultInterface
+ instance it was constructed with.
+ PhpSchool\PhpWorkshop\ResultRenderer\ResultsRenderer
+ has some helper methods on it for rendering styling:
+
+
+
+ style($string, $colourOrStyle)
+ - Use to style a string, eg.
+ bold
+ ,
+ green
+ . It accepts an array of styles or one style as a string
+ lineBreak()
+ - Use to render a line break, to separate content.
+ center()
+ - Pad a string according to the terminal width so it displays in the center
+ PhpSchool\PhpWorkshop\Factory\ResultRendererFactory
+ .
+ FailureInterface
+ you will need to map it to an existing renderer, or most likely you will need to write a custom renderer, and map it to that.
+
If you don't know what result renderers are, go ahead and read - this article first. We will be continuing on from the previous article, - let's go ahead and create the renderer.
+ + +
+ If you don't know what result renderers are, go ahead and read
+
mkdir src/ResultRenderer
-touch src/ResultRenderer/CodingStandardFailureRenderer.php
-<?php
+ 2. Write the renderer class
+
+
+
+<?php
namespace PhpSchool\SimpleMath\ResultRenderer;
@@ -57,21 +64,32 @@ class CodingStandardFailureRenderer implements ResultRendererInterface
return implode("\n", $output);
}
}
-
-
-
-This is really simple: the render(ResultsRenderer $renderer) just returns a string
-representation of the result, we style the results a little in a bullet pointed list, highlighting them red.
-We also add a title which describes the coding standard used.
That's basically it - we just need to register the renderer with the application.
- -Now you need to tell the application about your new result. Open up app/bootstrap.php and
-after the application object is created you just call addResult with the result class name
- and the result renderer class name. Your final app/bootstrap.php file should look something like:
<?php
+
+
+
+
+ This is really simple: the
+ render(ResultsRenderer $renderer)
+ just returns a string representation of the result, we style the results a little in a bullet pointed list, highlighting them red. We also add a title which describes the coding standard used.
+
That's basically it - we just need to register the renderer with the application.
+ +
+ Now you need to tell the application about your new result. Open up
+ app/bootstrap.php
+ and after the application object is created you just call
+ addResult
+ with the result class name and the result renderer class name. Your final
+ app/bootstrap.php
+ file should look something like:
+
+<?php
ini_set('display_errors', 1);
date_default_timezone_set('Europe/London');
@@ -116,18 +134,27 @@ $app->setFgColour('red');
$app->setBgColour('black');
return $app;
-
-
-
-= $this->renderContentHeader('try-it-out', 'Try it out!') ?>
-
-Run the workshop and select the Mean Average exercise. Verifying a solution which does not pass the
- PSR2 coding standard will yield the following output:
-
-
-
-You can see the finished, working code on the custom-result branch of the
- tutorial repository.
+ Run the workshop and select the Mean Average exercise. Verifying a solution which does not pass the
+ PSR2
+ coding standard will yield the following output:
+
+
+
+
+ You can see the finished, working code on the
+ custom-result
+ branch of the
+ tutorial repository
+ .
+
In this article we will build a custom result for our PSR2 check. In the next article, we will build the renderer for it, as the result is fairly useless without it.
+ ++ We want to be able to list out each of the coding standard violations as part of the feedback to the student. To do this, we will write our result (which is mostly a simple object containing the + data) and then we will update our check to parse the violations and use the new result class. +
+ +If you are carrying on from the previous article then you can skip this first step of grabbing the tutorial workshop.
+ +
+ As usual we will use the already built tutorial workshop as a base - the finished code is available on the
+ custom-result
+ branch of the
+ tutorial repository
+ . We will start fresh from the
+ custom-interface-check
+ branch for this tutorial, so if you haven't already got it, git clone it and install the dependencies:
+
+<?php
+
+namespace PhpSchool\SimpleMath\Result;
+
+use PhpSchool\PhpWorkshop\Check\CheckInterface;
+use PhpSchool\PhpWorkshop\Result\FailureInterface;
+
+class CodingStandardFailure implements FailureInterface
+{
+
+ /**
+ * @var string
+ */
+ private $name;
+
+ /**
+ * @var string
+ */
+ private $codingStandard;
+
+ /**
+ * @var array
+ */
+ private $errors;
+
+ /**
+ * @param string $name
+ * @param string $codingStandard
+ * @param array $errors
+ */
+ public function __construct($name, $codingStandard, array $errors)
+ {
+ $this->name = $name;
+ $this->codingStandard = $codingStandard;
+ $this->errors = $errors;
+ }
+
+ /**
+ * @param CheckInterface $check
+ * @param $codingStandard
+ * @param array $errors
+ * @return static
+ */
+ public static function fromCheckAndOutput(CheckInterface $check, $codingStandard, array $errors)
+ {
+ return new static($check->getName(), $codingStandard, $errors);
+ }
+
+ /**
+ * @return string
+ */
+ public function getCheckName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * @return string
+ */
+ public function getCodingStandard()
+ {
+ return $this->codingStandard;
+ }
+
+ /**
+ * @return array
+ */
+ public function getErrors()
+ {
+ return $this->errors;
+ }
+}
+
+
+ This is a simple class which takes in the check name, the standard used & and an array of violations.
+ getCheckName()
+ should return the name of the check this result represents, this is used when rendering the results to the student by the workshop framework. This is the only method required by the interface
+ PhpSchool\PhpWorkshop\Result\FailureInterface
+ .
+
+ We need to update our check to use the new result class, and we need to parse the violations from
+ phpcs
+ . We will only be making changes in the
+ check
+ method and the final method should look like the following:
+
+<?php
+public function check(ExerciseInterface $exercise, Input $input)
+{
+ if (!$exercise instanceof Psr2ExerciseCheck) {
+ throw new \InvalidArgumentException;
+ }
+
+ $standard = $exercise->getStandard();
+
+ if (!in_array($standard, ['PSR1', 'PSR2', 'PEAR'])) {
+ throw new \InvalidArgumentException('Standard is not supported');
+ }
+
+ $phpCsBinary = __DIR__ . '/../../vendor/bin/phpcs';
+ $cmd = sprintf('%s %s --standard=%s --report=json', $phpCsBinary, $input->getArgument('program'), $standard);
+ exec($cmd, $output, $exitCode);
+
+ if ($exitCode === 0) {
+ return new Success($this->getName());
+ }
+
+ $errors = json_decode($output[0], true)['files'][$input->getArgument('program')];
+ $errors = array_map(function ($error) {
+ return sprintf('Line %d, Column %d: %s', $error['line'], $error['column'], $error['message']);
+ }, $errors['messages']);
+
+ return CodingStandardFailure::fromCheckAndOutput($this, $standard, $errors);
+}
+
+ --report=json
+ to give us the report in json, which makes it easier to parse.
+ json_decode
+ the first line of output from
+ phpcs
+ .
+ + In the previous section, we learned of all the events dispatched throughout the process of verifying and running a student's solution to an exercise. In this this section we will learn how these + events can be used to build a + Listener Check + . +
+ +
+ We learned about
+ Simple Checks
+ in
+
+ Listener Checks are one of the most complex components of the workshop application, so in order to demonstrate their use-case, we will build a + Listener Check + which allows us to interact with + Couch DB + . We will then build an exercise in our tutorial application which utilises this check. +
+ +Before we build anything we should design our check. What should it do?
+ +Couch DB is a NoSQL database, which stores data as JSON documents and it's API is provided via regular HTTP.
+ +So, we want to introduce the features of Couch DB via this Listener Check. What should it do?
+ +CLI
+ type exercises.
+ Reading this specification we can see that we will need to hook in to various events to provide this functionality, we will now break down each point and decide what events to listen to.
+ +
+ We will need to create databases in both
+ verify
+ &
+ run
+ mode, we can do this immediately in our
+ attach
+ method, which is automatically called when we register our check within an exercise.
+
+ We will need to allow the exercise to seed the database, we should do this early on
+ verify.start
+ &
+ run.start
+ are the earliest events dispatched. These sound like good candidates to perform this task. We will pass a client object to the exercise
+ seed
+ method so they can create documents.
+
+ We will need to pass the database names to the programs (student's solution & the reference solution) so the programs can access it via the
+ $argv
+ array. We can do this with any events which trigger with an instance of
+ CliExecuteEvent
+ . We can use
+ cli.verify.reference-execute.pre
+ ,
+ cli.verify.student-execute.pre
+ &
+ cli.run.student-execute.pre
+ .
+
+ We will need to allow the exercise to verify the database, we should do this after output verification has finished. We can pick one of the last events triggered,
+ verify.finish
+ will do! We will pass the database client object again to the exercise
+ verify
+ method so they can verify the state of the database.
+
+ We will need to remove the databases we created at the end of the process. We can use
+ verify.finish
+ &
+ run.finish
+ to do this. We will also listen to
+ cli.verify.reference-execute.fail
+ so in case something goes wrong, we still cleanup.
+
+ The finished + Couch DB check + is available as a separate Composer package for you to use in your workshops right away, but, for the sake of this tutorial we will build it using the + tutorial application + as a base so we do not have to setup a new project with composer files, register it with + Packagist + and so on. +
+ +
+ We will start fresh from the
+ master
+ branch for this tutorial, so if you haven't already got it, git clone it and install the dependencies:
+
We will use this library to interact with Couch DB.
+
+ We mentioned before that we needed a way for the exercise to seed and verify the database, so we will define an interface which describes these methods which the exercise must implement for the
+ Couch DB check. These methods will automatically be invoked by the check. Open up
+ src/ExerciseCheck/CouchDbExerciseCheck.php
+ and add the following code to it:
+
+<?php
+
+namespace PhpSchool\SimpleMath\ExerciseCheck;
+
+use Doctrine\CouchDB\CouchDBClient;
+
+interface CouchDbExerciseCheck
+{
+ /**
+ * @param CouchDBClient $couchDbClient
+ * @return void
+ */
+ public function seed(CouchDBClient $couchDbClient);
+
+ /**
+ * @param CouchDBClient $couchDbClient
+ * @return bool
+ */
+ public function verify(CouchDBClient $couchDbClient);
+}
+
+
+ We define, two methods
+ seed()
+ &
+ verify()
+ , both receive an instance of
+ CouchDBClient
+ which will be connected to the database created for the student,
+ seed()
+ should be called before the student's solution is run and
+ verify()
+ should be called after the student's solution is run.
+
http://localhost:5984/
+ as is default when Couch DB is installed.
+
+ Now we write the check - there is quite a lot of code here so we will do it in steps, open up
+ src/Check/CouchDbCheck.php
+ and start with the following:
+
+<?php
+
+namespace PhpSchool\SimpleMath;
+
+use Doctrine\CouchDB\CouchDBClient;
+use Doctrine\CouchDB\HTTP\HTTPException;
+use PhpSchool\PhpWorkshop\Check\ListenableCheckInterface;
+use PhpSchool\PhpWorkshop\Event\EventDispatcher;
+use PhpSchool\SimpleMath\ExerciseCheck\CouchDbExerciseCheck;
+
+class CouchDbCheck implements ListenableCheckInterface
+{
+ /**
+ * @var string
+ */
+ private static $studentDb = 'phpschool-student';
+
+ /**
+ * @var string
+ */
+ private static $solutionDb = 'phpschool';
+
+ /**
+ * Return the check's name
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return 'Couch DB Verification Check';
+ }
+
+ /**
+ * This returns the interface the exercise should implement
+ * when requiring this check
+ *
+ * @return string
+ */
+ public function getExerciseInterface()
+ {
+ return CouchDbExerciseCheck::class;
+ }
+
+ /**
+ * @param EventDispatcher $eventDispatcher
+ */
+ public function attach(EventDispatcher $eventDispatcher)
+ {
+
+ }
+}
+
+
+ There is not much going on here - we define
+ getName()
+ which is the name of our check, and
+ getExerciseInterface()
+ which should return the FQCN (Fully Qualified Class Name) of the interface we just defined earlier. This is so the workshop framework can check the exercise implements it. We also define some
+ properties which describe the names of the Couch DB databases we will setup: one for the student and one for the reference solution.
+
+ The most important thing to note in this check is that we implement
+ PhpSchool\PhpWorkshop\Check\ListenableCheckInterface
+ instead of
+ PhpSchool\PhpWorkshop\Check\SimpleCheckInterface
+ . They both inherit from
+ PhpSchool\PhpWorkshop\Check\CheckInterface
+ which introduces
+ getName()
+ &
+ getExerciseInterface()
+ .
+ ListenableCheckInterface
+ brings in one other additional method:
+ attach()
+ . This method is called immediately when an exercise requires any
+ Listener Check
+ and is passed an instance of
+ PhpSchool\PhpWorkshop\Event\EventDispatcher
+ allowing the check to listen to any events which might be dispatched throughout the verifying/running process.
+
Our check will listen to a number of those events so we will build this method up step by step.
+ +
+ The first thing we need to do is create the two databases, so we create two Couch DB clients and issue the
+ createDatabase
+ method:
+
+$studentClient = CouchDBClient::create(['dbname' => static::$studentDb); +$solutionClient = CouchDBClient::create(['dbname' => static::$solutionDb]); + +$studentClient->createDatabase($studentClient->getDatabase()); +$solutionClient->createDatabase($solutionClient->getDatabase()); ++
+ We need to allow the exercise to seed the database to create documents, for example. The database for the student and the reference solution should contain the same data, but they must be + different databases. +
+ +seed
+ method. The student's solution should remove that document. If the student's solution and the reference solution shared one database, then the reference solution would run first and remove the
+ row. Then the student's solution would run...it can't remove the document because it's not there anymore!
+ seed()
+ again because
+ seed()
+ can return dynamic data and then the student's solution and the reference solution would run with different data sets; which makes it impossible to compare their output.
+
+$eventDispatcher->listen('verify.start', function (Event $e) use ($studentClient, $solutionClient) {
+ $e->getParameter('exercise')->seed($studentClient);
+ $this->replicateDbFromStudentToSolution($studentClient, $solutionClient);
+});
+
+
+ We listen to the
+ verify.start
+ event which (as you can probably infer) triggers right at the start of the verify process. The listener is an anonymous function that grabs the exercise instance from the event and calls the
+ seed()
+ method passing in the
+ CouchDBClient
+ which references the database created for the student. We also need to seed the database for reference solution, we need it to be exactly the same as the student's so we basically select all
+ documents from the student database and insert them in to the reference solution database. We do this in the method
+ replicateDbFromStudentToSolution
+ . This method looks like the following:
+
+/**
+ * @param CouchDBClient $studentClient
+ * @param CouchDBClient $solutionClient
+ * @throws \Doctrine\CouchDB\HTTP\HTTPException
+ */
+private function replicateDbFromStudentToSolution(CouchDBClient $studentClient, CouchDBClient $solutionClient)
+{
+ $response = $studentClient->allDocs();
+
+ if ($response->status !== 200) {
+ return;
+ }
+
+ foreach ($response->body['rows'] as $row) {
+ $doc = $row['doc'];
+
+ $data = array_filter($doc, function ($key) {
+ return !in_array($key, ['_id', '_rev']);
+ }, ARRAY_FILTER_USE_KEY);
+
+ try {
+ $solutionClient->putDocument(
+ $data,
+ $doc['_id']
+ );
+ } catch (HTTPException $e) {
+ }
+ }
+}
+
+
+ When in run mode, no output is compared - we merely run the student's solution - so we only need to seed the student's database. There is a similar event to
+ verify.start
+ when in run mode, aptly named
+ run.start
+ , let's use that:
+
+$eventDispatcher->listen('run.start', function (Event $e) use ($studentClient) {
+ $e->getParameter('exercise')->seed($studentClient);
+});
+
+
+ We need the programs (student solution & the reference solution) to have access to their respective database names, the best way to do this is via command line arguments - we can add arguments to
+ the list of arguments to be sent to the programs with any event which triggers with an instance of
+ CliExecuteEvent
+ . It exposes the
+ prependArg()
+ &
+ appendArg()
+ methods.
+
+ We use
+ cli.verify.reference-execute.pre
+ to prepend the reference database name to the reference solution program when in
+ verify
+ mode and we use
+ cli.verify.student-execute.pre
+ &
+ cli.run.student-execute.pre
+ to prepend the student database name to the student solution in
+ verify
+ &
+ run
+ mode, respectively.
+
+$eventDispatcher->listen('cli.verify.reference-execute.pre', function (CliExecuteEvent $e) {
+ $e->prependArg('phpschool');
+});
+
+$eventDispatcher->listen(
+ ['cli.verify.student-execute.pre', 'cli.run.student-execute.pre'],
+ function (CliExecuteEvent $e) {
+ $e->prependArg('phpschool-student');
+ }
+);
+
+
+ After the programs have been executed, we need a way to let the exercise verify the contents of the database. We hook on to an event during the
+ verify
+ process named
+ verify.finish
+ (this is the last event in the verify process) and insert a verifier function. We don't need to verify the database in
+ run
+ mode because all we do in run mode is
+ run
+ the students submission in the correct environment (with args and database).
+
+$eventDispatcher->insertVerifier('verify.finish', function (Event $e) use ($studentClient) {
+ $verifyResult = $e->getParameter('exercise')->verify($studentClient);
+
+ if (false === $verifyResult) {
+ return Failure::fromNameAndReason($this->getName(), 'Database verification failed');
+ }
+
+ return Success::fromCheck($this);
+});
+
+
+ Verify functions are used to inject results into the result set, which is then reported to the student. So you can see that if the
+ verify
+ method returns
+ true
+ we return a
+ Success
+ to the result set but if it returns false we return a
+ Failure
+ result, with a message, so the student knows what went wrong.
+
+ The final stage is to remove the databases, we listen to
+ verify.post.execute
+ for the verify process &
+ run.finish
+ for the run process:
+
+$eventDispatcher->listen(
+ [
+ 'verify.post.execute',
+ 'run.finish'
+ ],
+ function (Event $e) use ($studentClient, $solutionClient) {
+ $studentClient->deleteDatabase(static::$studentDb);
+ $solutionClient->deleteDatabase(static::$solutionDb);
+ }
+);
+
+ Our final check should look like:
+ +
+<?php
+
+namespace PhpSchool\SimpleMath;
+
+use Doctrine\CouchDB\CouchDBClient;
+use Doctrine\CouchDB\HTTP\HTTPException;
+use PhpSchool\PhpWorkshop\Check\ListenableCheckInterface;
+use PhpSchool\PhpWorkshop\Event\EventDispatcher;
+use PhpSchool\SimpleMath\ExerciseCheck\CouchDbExerciseCheck;
+
+class CouchDbCheck implements ListenableCheckInterface
+{
+ /**
+ * @var string
+ */
+ private static $studentDb = 'phpschool-student';
+
+ /**
+ * @var string
+ */
+ private static $solutionDb = 'phpschool';
+
+ /**
+ * Return the check's name
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return 'Couch DB Verification Check';
+ }
+
+ /**
+ * This returns the interface the exercise should implement
+ * when requiring this check
+ *
+ * @return string
+ */
+ public function getExerciseInterface()
+ {
+ return CouchDbExerciseCheck::class;
+ }
+
+ /**
+ * @param EventDispatcher $eventDispatcher
+ */
+ public function attach(EventDispatcher $eventDispatcher)
+ {
+ $studentClient = CouchDBClient::create(['dbname' => static::$studentDb);
+ $solutionClient = CouchDBClient::create(['dbname' => static::$solutionDb]);
+
+ $studentClient->createDatabase($studentClient->getDatabase());
+ $solutionClient->createDatabase($solutionClient->getDatabase());
+
+ $eventDispatcher->listen('verify.start', function (Event $e) use ($studentClient, $solutionClient) {
+ $e->getParameter('exercise')->seed($studentClient);
+ $this->replicateDbFromStudentToSolution($studentClient, $solutionClient);
+ });
+
+ $eventDispatcher->listen('run.start', function (Event $e) use ($studentClient) {
+ $e->getParameter('exercise')->seed($studentClient);
+ });
+
+ $eventDispatcher->listen('cli.verify.reference-execute.pre', function (CliExecuteEvent $e) {
+ $e->prependArg('phpschool');
+ });
+
+ $eventDispatcher->listen(
+ ['cli.verify.student-execute.pre', 'cli.run.student-execute.pre'],
+ function (CliExecuteEvent $e) {
+ $e->prependArg('phpschool-student');
+ }
+ );
+
+ $eventDispatcher->listen(
+ [
+ 'verify.post.execute',
+ 'run.finish'
+ ],
+ function (Event $e) use ($studentClient, $solutionClient) {
+ $studentClient->deleteDatabase(static::$studentDb);
+ $solutionClient->deleteDatabase(static::$solutionDb);
+ }
+ );
+ }
+
+ /**
+ * @param CouchDBClient $studentClient
+ * @param CouchDBClient $solutionClient
+ * @throws \Doctrine\CouchDB\HTTP\HTTPException
+ */
+ private function replicateDbFromStudentToSolution(CouchDBClient $studentClient, CouchDBClient $solutionClient)
+ {
+ $response = $studentClient->allDocs();
+
+ if ($response->status !== 200) {
+ return;
+ }
+
+ foreach ($response->body['rows'] as $row) {
+ $doc = $row['doc'];
+
+ $data = array_filter($doc, function ($key) {
+ return !in_array($key, ['_id', '_rev']);
+ }, ARRAY_FILTER_USE_KEY);
+
+ try {
+ $solutionClient->putDocument(
+ $data,
+ $doc['_id']
+ );
+ } catch (HTTPException $e) {
+ }
+ }
+ }
+}
+
+
+ So then, this Couch DB check is not much use if we don't utilise it! let's build an exercise which retrieves a document from a database, sums a bunch of numbers and adds the total to the document,
+ finally we should output the total. The document with the numbers in it will be automatically created by our exercise in the
+ seed()
+ method and will be random.
+
+ As always we will start from a fresh copy of the + tutorial application + : +
+ +We will use the check that is available in the already built Composer package, so, pull it in to your project:
+ +doctrine/couchdb
+ even though it is a dependency of
+ php-school/couch-db-check
+ because there is no stable release available. Indirect dependencies cannot install non-stable versions.
+
+ Create a problem file in
+ exercises/couch-db-exercise/problem/problem.md
+ . Here we describe the problem we mentioned earlier when we decided what we wanted our exercise to do:
+
+Write a program that accepts the name of database and a Couch DB document ID. You should load this document using the
+provided ID from the provided database. In the document will be a key named `numbers`. You should add them all up
+and add the total to the document under the key `total`. You should save the document and finally output the total to
+the console.
+
+You must have Couch DB installed before you run this exercise, you can get it here:
+ [http://couchdb.apache.org/#download]()
+
+----------------------------------------------------------------------
+## HINTS
+
+You could use a third party library to communicate with the Couch DB instance, see this doctrine library:
+ [https://github.com/doctrine/couchdb-client]()
+
+Or you could interact with it using a HTTP client such as Guzzle:
+ [https://github.com/guzzle/guzzle]()
+
+Or you could simply use `curl`.
+
+Check out how to interact with Couch DB documents here:
+ [http://docs.couchdb.org/en/1.6.1/intro/api.html#documents]()
+
+You will need to do this via PHP.
+
+You specifically need the `GET` and `PUT` methods, or if you are using a library abstraction, you will need to
+`find` and `update` the document.
+
+
+You can use the doctrine library like so:
+
+```php
+<?php
+require_once __DIR__ . '/vendor/autoload.php';
+
+use Doctrine\CouchDB\CouchDBClient;
+$client = CouchDBClient::create(['dbname' => $dbName]);
+
+//get doc
+$doc = $client->findDocument($docId);
+
+//update doc
+$client->putDocument($updatedDoc, $docId, $docRevision);
+```
+
+`{appname}` will be supplying arguments to your program when you run `{appname} verify program.php` so you don't need to supply them yourself. To test your program without verifying it, you can invoke it with `{appname} run program.php`. When you use `run`, you are invoking the test environment that `{appname}` sets up for each exercise.
+
+----------------------------------------------------------------------
+
+ We note that the student must have Couch DB installed, we give a few links, an example of how to use the Doctrine Couch DB client and we describe the actual task.
+ +
+ Create the exercise in
+ src/Exercise/CouchDbExercise.php
+ :
+
+<?php
+
+namespace PhpSchool\SimpleMath\Exercise;
+
+use Doctrine\CouchDB\CouchDBClient;
+use PhpSchool\CouchDb\CouchDbCheck;
+use PhpSchool\CouchDb\CouchDbExerciseCheck;
+use PhpSchool\PhpWorkshop\Exercise\AbstractExercise;
+use PhpSchool\PhpWorkshop\Exercise\CliExercise;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseType;
+use PhpSchool\PhpWorkshop\ExerciseDispatcher;
+
+class CouchDbExercise extends AbstractExercise implements
+ ExerciseInterface,
+ CliExercise,
+ CouchDbExerciseCheck
+{
+ /**
+ * @var string
+ */
+ private $docId;
+
+ /**
+ * @var int
+ */
+ private $total;
+
+ /**
+ * @return string
+ */
+ public function getName()
+ {
+ return 'Couch DB Exercise';
+ }
+
+ /**
+ * @return string
+ */
+ public function getDescription()
+ {
+ return 'Intro to Couch DB';
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getArgs()
+ {
+ return [$this->docId];
+ }
+
+ /**
+ * @return ExerciseType
+ */
+ public function getType()
+ {
+ return ExerciseType::CLI();
+ }
+
+ /**
+ * @param ExerciseDispatcher $dispatcher
+ */
+ public function configure(ExerciseDispatcher $dispatcher)
+ {
+ $dispatcher->requireCheck(CouchDbCheck::class);
+ }
+
+ /**
+ * @param CouchDBClient $couchDbClient
+ * @return void
+ */
+ public function seed(CouchDBClient $couchDbClient)
+ {
+ $numArgs = rand(4, 20);
+ $args = [];
+ for ($i = 0; $i < $numArgs; $i ++) {
+ $args[] = rand(1, 100);
+ }
+
+ list($id) = $couchDbClient->postDocument(['numbers' => $args]);
+
+ $this->docId = $id;
+ $this->total = array_sum($args);
+ }
+
+ /**
+ * @param CouchDBClient $couchDbClient
+ * @return bool
+ */
+ public function verify(CouchDBClient $couchDbClient)
+ {
+ $total = $couchDbClient->findDocument($this->docId);
+
+ return isset($total->body['total']) && $total->body['total'] == $this->total;
+ }
+}
+
+
+ So - in
+ seed
+ we create a random number of random numbers and insert a document containing these numbers under a key named
+ numbers
+ . We store the total (for verification purposes) and also the document ID (this is auto generated by Couch DB) so we can pass it to the solutions as an argument.
+
+ In the
+ verify
+ method, we try load the document with the stored ID, check for the presence of the
+ total
+ property and check that it is equal to the stored total we set during
+ seed
+ .
+
+ In
+ configure()
+ we require our Couch DB check and in
+ getType()
+ we inform the the workshop framework that this is a CLI type exercise.
+
+ In
+ getArgs()
+ we return the Document ID we set during
+ seed
+ .
+
seed
+ is invoked from an event which is dispatched before
+ getArgs
+ , we can rely on anything set there.
+ php my-solution.php phpschool-student 18
+ . The argument
+ phpschool-student
+ being the database name created for the student by the check (remember the check prepends this argument to the argument list) and 18 being the ID of the document we created!
+
+ Our reference solution will also use the Doctrine Couch DB library - let's go ahead and create the solution in
+ exercises/couch-db-exercise/solution
+ . We will need three files
+ composer.json
+ ,
+ composer.lock
+ and
+ solution.php:
+
+<?php +require_once __DIR__ . '/vendor/autoload.php'; + +use Doctrine\CouchDB\CouchDBClient; + +$client = CouchDBClient::create(['dbname' => $argv[1]]); +$doc = $client->findDocument($argv[2])->body; + +$total = array_sum($doc['numbers']); +$doc['total'] = $total; +$client->putDocument(['total' => $total, 'numbers' => $doc['numbers']], $argv[2], $doc['_rev']); +echo $total; ++
+{
+ "name": "php-school/couch-db-exercise-ref-solution",
+ "description": "Intro to Couch DB",
+ "require": {
+ "doctrine/couchdb": "^1.0@beta"
+ }
+}
+
+ composer.lock
+ is auto generated by Composer, by running
+ composer install
+ in
+ exercises/couch-db-exercise/solution
+
+ Now we have to add the factories for our check and exercise and register it with the application, add the following to
+ app/config.php
+ and don't forget to import the necessary classes.
+
+CouchDbExercise::class => object(), +CouchDbCheck::class => object(), ++
The result should look like:
+ +
+<?php
+
+use function DI\factory;
+use function DI\object;
+use Interop\Container\ContainerInterface;
+use PhpSchool\SimpleMath\Exercise\GetExercise;
+use PhpSchool\CouchDb\CouchDbCheck;
+use PhpSchool\SimpleMath\Exercise\CouchDbExercise;
+use PhpSchool\SimpleMath\Exercise\Mean;
+use PhpSchool\SimpleMath\Exercise\PostExercise;
+use PhpSchool\SimpleMath\MyFileSystem;
+
+return [
+ //Define your exercise factories here
+ Mean::class => factory(function (ContainerInterface $c) {
+ return new Mean($c->get(\Symfony\Component\Filesystem\Filesystem::class));
+ }),
+
+ CouchDbExercise::class => object(),
+ CouchDbCheck::class => object(),
+];
+
+
+ Finally we need to tell the application about our new check and exercise in
+ app/bootstrap.php
+ . After the application object is created you just call
+ addCheck
+ &
+ addExercise
+ with the name of check class and exercise class respectively. Your final
+ app/bootstrap.php
+ file should look something like:
+
+<?php
+
+ini_set('display_errors', 1);
+date_default_timezone_set('Europe/London');
+switch (true) {
+ case (file_exists(__DIR__ . '/../vendor/autoload.php')):
+ // Installed standalone
+ require __DIR__ . '/../vendor/autoload.php';
+ break;
+ case (file_exists(__DIR__ . '/../../../autoload.php')):
+ // Installed as a Composer dependency
+ require __DIR__ . '/../../../autoload.php';
+ break;
+ case (file_exists('vendor/autoload.php')):
+ // As a Composer dependency, relative to CWD
+ require 'vendor/autoload.php';
+ break;
+ default:
+ throw new RuntimeException('Unable to locate Composer autoloader; please run "composer install".');
+}
+
+use PhpSchool\CouchDb\CouchDbCheck;
+use PhpSchool\PhpWorkshop\Application;
+use PhpSchool\SimpleMath\Exercise\CouchDbExercise;
+use PhpSchool\SimpleMath\Exercise\Mean;
+
+$app = new Application('Simple Math', __DIR__ . '/config.php');
+
+$app->addExercise(Mean::class);
+$app->addExercise(CouchDbExercise::class);
+$app->addCheck(CouchDbCheck::class);
+
+$art = <<<ART
+ ∞ ÷ ∑ ×
+
+ PHP SCHOOL
+SIMPLE MATH
+ART;
+
+$app->setLogo($art);
+$app->setFgColour('red');
+$app->setBgColour('black');
+
+return $app;
+
+
+ Make sure you have Couch DB installed, run the workshop and select the
+ Couch DB Exercise
+ exercise.
+
+ Try verifying with the solution below which incorrectly sets the total to
+ 30
+ , hopefully you will see a failure.
+
+<?php +require_once __DIR__ . '/vendor/autoload.php'; + +use Doctrine\CouchDB\CouchDBClient; + +$client = CouchDBClient::create(['dbname' => $argv[1]]); +$doc = $client->findDocument($argv[2])->body; + +$total = 30; //we guess total is 30 +$doc['total'] = $total; +$client->putDocument(['total' => $total, 'numbers' => $doc['numbers']], $argv[2], $doc['_rev']); +echo $total; ++
+
+
+ And a solution which does pass will yield the output:
+ + +
+
+
diff --git a/assets/components/Website/Docs/Sections/Reference/EventDescription.vue b/assets/components/Website/Docs/Sections/Reference/EventDescription.vue
new file mode 100644
index 00000000..aa65fbbb
--- /dev/null
+++ b/assets/components/Website/Docs/Sections/Reference/EventDescription.vue
@@ -0,0 +1,34 @@
+
+
+
+
+ Event Class:
+ {{ eventClass }}
+
Arguments:
+{{ arg.type }}
+ Arguments - None
+
+ There are various events triggered throughout the verifying and running of exercises. These events can be used by
+
+ Each event implements
+ PhpSchool\PhpWorkshop\Event\EventInterface
+ where you can grab the parameters like
+ $event->getParameter('myParam');
+ some events may have convenience methods for accessing certain parameters, please refer to the particular event class for more info.
+
There are 4 routes through the application, and the lists of events below each represent a timeline of one of those routes.
+ +
+ This is the route taken when using the
+ verify
+ command on a
+ CLI
+ type exercise.
+
+ This is the route taken when using the
+ run
+ command on a
+ CLI
+ type exercise.
+
+ This is the route taken when using the
+ verify
+ command on a
+ CGI
+ type exercise.
+
+ This is the route taken when using the
+ run
+ command on a
+ CGI
+ type exercise.
+
Events can be listened to by attaching to the event dispatcher with any valid PHP callable:
+ +
+<?php
+
+$eventDispatcher = $container->get(PhpSchool\PhpWorkshop\Event\EventDispatcher:class);
+$eventDispatcher->listen('verify.start', function (Event $event) {
+ //do something
+});
+
+// you can also listen to multiple events in one call
+
+$eventDispatcher->listen(['verify.start', 'run.start'], function (Event $event) {
+ //do something
+});
+
+
+ With the event dispatcher you can even do more interesting things, such as, at any event, you can insert a verifier (any valid PHP callable) - it will be passed the event, the same as a normal
+ listener, but it must return an implementation of
+ PhpSchool\PhpWorkshop\Result\ResultInterface
+ . This will be evaluated and injected in to the results for reporting on the CLI.
+ PhpSchool\PhpWorkshop\Result\SuccessInterface
+ instances will be treated as successes and
+ PhpSchool\PhpWorkshop\Result\FailureInterface
+ instances will be treated as failures. So you can actually fail a verification attempt via an event.
+
+ This is useful for + Listener Checks + , for example, towards the end of the verifying process, you may want to verify that some data was inserted to a database. If it was not you will return a failure, which will be displayed on the + CLI and will cause the verification attempt to fail. +
+ +How to insert verifiers:
+ +
+<?php
+
+$eventDispatcher = $container->get(PhpSchool\PhpWorkshop\Event\EventDispatcher:class);
+$eventDispatcher->insertVerifier('verify.finish', function (Event $event) {
+ if (!$this->checkDb()) {
+ return Failure::fromNameAndReason('DB Check', 'DB Verification failed!');
+ }
+ return new Success('DB Check');
+});
+
+ + The main task of the workshop framework is to compare the output of the student's solution with the output of the reference solution, it is not however, limited to this. In this article we will + introduce the workshop exercise check feature. +
+ +Checks are extra verifications that can be performed during the process of verifying a students solution to an exercise. Each exercise can utilise any amount of additional checks.
+ +
+ There are two types of checks:
+ Simple
+ &
+ Listener
+ . What are the differences? Read on!
+
+ Simple checks are basically a block of code that can run before or after verifying the output of the students solution. There can be many checks, which will be run sequentially - each check can + return a success or failure and this will be injected into the result set. +
+ +
+ Listener checks are more advanced - when you add a listener type check, it will be passed an event dispatcher (
+ PhpSchool\PhpWorkshop\Event\EventDispatcher
+ ) which can be used to listen to various events throughout the life-cycle of verifying. Learn more about Listener Checks and the events which can be listened to in the
+
+ In order to specify that your exercise requires additional checks you should implement the method
+ configure
+ in your exercise. It will be passed an instance of
+ ExerciseDispatcher
+ which you can interact with and tell it which checks your exercise requires. The method
+ configure
+ is already implemented in
+ AbstractExercise
+ , but is empty, so you don't need to call
+ parent::configure($dispatcher)
+ inside your method.
+
+class MyExercise extends AbstractExercise implements ExerciseInterface
+{
+ ...snip
+
+ /**
+ * @param ExerciseDispatcher $dispatcher
+ */
+ public function configure(ExerciseDispatcher $dispatcher)
+ {
+ $dispatcher->requireCheck(ComposerCheck::class);
+ }
+}
+
+
+ This basically informs the workshop framework that when verifying the student's solution to this exercise, we should also run the
+ ComposerCheck
+ check. To learn what the
+ ComposerCheck
+ check actually does go to the
+
+ The second and final step is to implement the correct interface in your exercise. If you do not do this the workshop framework will throw an exception when it tries to run the check. Each check
+ has an interface you need to implement when requiring it in your exercise. This interface can be found by visiting the
+ getExerciseInterface()
+ method of the check. This method returns a string containing the FQCN (Fully Qualified Class Name) of the interface the check requires your exercise to implement.
+
+ Some of the bundled checks only require you to implement
+ PhpSchool\PhpWorkshop\Exercise\ExerciseInterface
+ which you will have to do anyway as part of building your exercise. Some checks require you to implement additional interfaces which introduce new methods to your exercise. These methods provide
+ the checks with the necessary information to execute. For example, the
+ ComposerCheck
+ requires you to implement the
+ PhpSchool\PhpWorkshop\ExerciseCheck\ComposerExerciseCheck
+ interface, which, in turn, requires your exercise to implement the method
+ getRequiredPackages
+ .
+
+ So, your exercise, taking advantage of the
+ ComposerExerciseCheck
+ may end up looking something like the following:
+
+class MyExercise extends AbstractExercise implements
+ ExerciseInterface,
+ ComposerExerciseCheck
+{
+ ...snip
+
+ /**
+ * @param ExerciseDispatcher $dispatcher
+ */
+ public function configure(ExerciseDispatcher $dispatcher)
+ {
+ $dispatcher->requireCheck(ComposerCheck::class);
+ }
+
+ /**
+ * @return array
+ */
+ public function getRequiredPackages()
+ {
+ return [
+ 'nikic/fast-route'
+ ];
+ }
+
+}
+
+ As we learned in the previous article, we can implement simple checking directly in our exercise if we don't want to build a check.
+ +However, Self Checking is rather un-flexible in that you can only hook in to the verify/run process at one particular point, immediately after all other checks have run.
+ +
+ We also described the events features in previous chapters
+
Check it out:
+ +
+<?php
+class MyExercise extends AbstractExercise implements ExerciseInterface
+{
+ ...snip
+
+ /**
+ * @param ExerciseDispatcher $dispatcher
+ */
+ public function configure(ExerciseDispatcher $dispatcher)
+ {
+ $dispatcher->requireCheck(ComposerCheck::class);
+
+ $dispatcher->getEventDispatcher()->listen('verify.start', function (Event $e) {
+ //do something
+ });
+ }
+}
+
+
+ You can grab the
+ EventDispatcher
+ from
+ ExerciseDispatcher
+ and listen to any events and insert verifiers just like we did in
+
Every CGI & CLI type exercise must have a reference solution. The solution represents a complete working example of how to solve the exercise's problem.
+ +The solution is used for a few things in the workshop:
+
+ A solution is represented by an implementation of
+ PhpSchool\PhpWorkshop\Solution\SolutionInterface
+ .
+
+ The workshop framework ships with two implementations of
+ PhpSchool\PhpWorkshop\Solution\SolutionInterface
+ to cover most scenarios:
+
solution.php
+
+ This is the default used by
+ PhpSchool\PhpWorkshop\Exercise\AbstractExercise
+ . So if you don't override the
+ getSolution()
+ method, the solution will be a single file named
+ solution.php
+ contained in the directory
+ exercises/exercise-name/solution
+ .
+
This would look like the following if you were to manually construct it:
+ +
+<?php
+require __DIR__ . '/vendor/autoload.php';
+
+use PhpSchool\PhpWorkshop\Solution\SingleFileSolution;
+
+$solution = SingleFileSolution::fromFile('/path/to/workshop/exercises/exercise-name/solution.php');
+
+var_dump($solution->getEntryPoint());
+//Outputs: "/path/to/workshop/exercises/exercise-name/solution.php"
+
+var_dump($solution->getBaseDirectory());
+//Outputs: "/path/to/workshop/exercises/exercise-name"
+
+var_dump($solution->hasComposerFile());
+//Outputs: "false";
+
+$files = $solution->getFiles();
+
+var_dump(count($files));
+//Outputs: 1
+
+$file = $files[0];
+
+var_dump($file->getBaseDirectory());
+//Outputs: "/path/to/workshop/exercises/exercise-name"
+
+var_dump($file->getRelativePath());
+//Outputs: "solution.php"
+
+var_dump($file->__toString());
+//Outputs: "/path/to/workshop/exercises/exercise-name/solution.php"
+
+var_dump($file->getContents());
+//Outputs: "contents-of-the-file"
+
+ It is possible that your solution contains more than one PHP file. Maybe you have some classes separated into different files, maybe you also pull in some dependencies via Composer. In either
+ case, you should use
+ PhpSchool\PhpWorkshop\Solution\DirectorySolution
+ .
+
+ Usage is simple, just pass it the directory and an (optional) entry point. You can also provide an optional list of files to exclude, more on that later. The entry point defaults to
+ solution.php
+ . The following is a depiction of a directory structure and the code to encompass the solution:
+
+/path/to/workshop/exercises/exercise-name/solution +├── SomeClass.php +└── solution.php ++
+ To return a directory solution when using the
+ PhpSchool\PhpWorkshop\Exercise\AbstractExercise
+ as a base for your exercise you can override the
+ getSolution
+ method with the following:
+
+<?php
+
+public function getSolution()
+{
+ return DirectorySolution::fromDirectory('/path/to/workshop/exercises/exercise-name');
+}
+
+ This would load any files in the directory given and treat
+ solution.php
+ as the entry point. Constructed manually this might look like:
+
+<?php
+require __DIR__ . '/vendor/autoload.php';
+
+use PhpSchool\PhpWorkshop\Solution\DirectorySolution;
+
+$solution = DirectorySolution::fromDirectory('/path/to/workshop/exercises/exercise-name/solution');
+
+var_dump($solution->getEntryPoint());
+//Outputs: "/path/to/workshop/exercises/exercise-name/solution/solution.php"
+
+var_dump($solution->getBaseDirectory());
+//Outputs: "/path/to/workshop/exercises/exercise-name/solution"
+
+var_dump($solution->hasComposerFile());
+//Outputs: "false";
+
+$files = $solution->getFiles();
+
+var_dump(count($files));
+//Outputs: 2
+
+$file1 = $files[0];
+
+var_dump($file1->getBaseDirectory());
+//Outputs: "/path/to/workshop/exercises/exercise-name/solution"
+
+var_dump($file1->getRelativePath());
+//Outputs: "index.php"
+
+var_dump($file1->__toString());
+//Outputs: "/path/to/workshop/exercises/exercise-name/solution/solution.php"
+
+var_dump($file1->getContents());
+//Outputs: "contents-of-the-file"
+
+$file2 = $files[1];
+
+var_dump($file2->getBaseDirectory());
+//Outputs: "/path/to/workshop/exercises/exercise-name/solution"
+
+var_dump($file2->getRelativePath());
+//Outputs: "SomeClass.php"
+
+var_dump($file2->__toString());
+//Outputs: "/path/to/workshop/exercises/exercise-name/solution/SomeClass.php"
+
+var_dump($file2->getContents());
+//Outputs: "contents-of-the-file"
+
+ If your solution looked like the below where your entry point is named
+ index.php
+ , you can provide the optional third parameter to the static constructor:
+ fromDirectory
+ . It must be the relative path of the file from the solution base directory.
+
+/path/to/workshop/exercises/exercise-name +├── SomeClass.php +└── index.php ++
+<?php
+require __DIR__ . '/vendor/autoload.php';
+
+use PhpSchool\PhpWorkshop\Solution\DirectorySolution;
+
+$solution = DirectorySolution::fromDirectory('/path/to/workshop/exercises/exercise-name', [], 'index.php');
+
+ solution.php
+ to keep things simple.
+ DirectorySolution
+ will throw an instance of
+ InvalidArgumentException
+ if the entry point does not exist in the directory given.
+
+ The method
+ getFiles
+ is used to find all the files in an solution. One use case is to display the contents of the files to the student when they have finished an exercise. This way they can compare Notes. Sometimes
+ you may want some files to be excluded from this. Perhaps you don't want the
+ composer.lock
+ file to be printed to the terminal as this can be quite long. To exclude some files from the solution, simply provide an array of excludes, relative to the base directory:
+
+<?php +require __DIR__ . '/vendor/autoload.php'; + +use PhpSchool\PhpWorkshop\Solution\DirectorySolution; + +$solution = DirectorySolution::fromDirectory( + '/path/to/workshop/exercises/exercise-name/solution', + [ + 'composer.lock', + 'vendor' + ] +);+
+ It is not actually necessary to exclude
+ composer.lock
+ or
+ vendor
+ as these are automatically appended to the list of excludes when using the static constructor
+ fromDirectory
+ .
+
+ The following files are excluded by default when using the static constructor
+ fromDirectory
+ :
+
+ If for some reason you do not want to ignore, say
+ composer.lock
+ but still
+ vendor
+ you can use the
+ __construct
+ method which does not have any default values:
+
+<?php +require __DIR__ . '/vendor/autoload.php'; + +use PhpSchool\PhpWorkshop\Solution\DirectorySolution; + +$solution = new DirectorySolution( + '/path/to/workshop/exercises/exercise-name/solution', + 'solution.php', + ['vendor'] +);+
__construct
+ and
+ fromDirectory
+ are slightly different.
+ __construct
+ is: $directory, $entryPoint, $excludes.
+ fromDirectory
+ is: $directory, $excludes, $entryPoint.
+
+ If you use a library via Composer then you should include the
+ composer.json
+ and
+ composer.lock
+ file in the solution base directory.
+ DirectorySolution
+ will detect the Composer files automatically. If there are Composer files available, the workshop will run a
+ composer install
+ in the solution base directory before invoking the solution.
+
+ If the
+ SingleFileSolution
+ or
+ DirectorySolution
+ implementations do not cover your needs, you can create your own by implementing the following interface:
+
+interface SolutionInterface
+{
+ /**
+ * @return string
+ */
+ public function getEntryPoint();
+
+ /**
+ * @return SolutionFile[]
+ */
+ public function getFiles();
+
+ /**
+ * @return string
+ */
+ public function getBaseDirectory();
+
+ /**
+ * @return bool
+ */
+ public function hasComposerFile();
+}
+ This method should return the name of the file which should be the entry point to your solution, in absolute form.
+ +
+ This method should return an array of files. Each file should be represented by an instance of
+ PhpSchool\PhpWorkshop\Solution\SolutionFile
+ .
+
This should return the absolute path to the directory of the solution.
+ +
+ This should return a boolean value depending on whether the solution has a
+ composer.lock
+ file present. If it does, before invoking the solution,
+ composer install
+ will be executed in the solution base directory. This saves you having to bundle the vendor directory in your workshop.
+
+ The exercise type indicated how the exercise should be verified, each exercise must have a type. There are three types of exercise types as of writing + CLI + , + CGI + & + CUSTOM + . In this section we will detail what the verification process for each exercise type is. +
+ +When selecting your exercise type you must return the valid type and implement the respective interface.
+ +
+ You choose the CLI type if you want the user to interact with the
+ $argv
+ and
+ $argc
+ super globals.
+
+ getArgs
+ should return an array of string arrays. These are the arguments to be passed to the PHP CLI executable. The number of arguments which can be returned is not limited. All arguments should be
+ strings. If multiple sets of arguments are specified then the program will be executed each time with those arguments.
+
+<?php
+
+namespace PhpSchool\MyWorkshop\Exercise;
+
+use PhpSchool\PhpWorkshop\Exercise\AbstractExercise;
+use PhpSchool\PhpWorkshop\Exercise\CliExercise;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseType;
+
+class MyExercise extends AbstractExercise implements ExerciseInterface, CliExercise
+{
+ ...snip
+
+ /**
+ * @return array
+ */
+ public function getArgs()
+ {
+ return [
+ ['arg1', 'arg2', 'arg3'], //first execution
+ ['run2-arg1', 'run2-arg2', 'run2-arg3'] //second execution
+ ];
+ }
+
+ /**
+ * @return ExerciseType
+ */
+ public function getType()
+ {
+ return ExerciseType::CLI();
+ }
+}
+ This exercise would cause PHP to be invoked like so:
+php solution.php 'arg1' 'arg2' 'arg3'+
And then:
+php solution.php 'run2-arg1' 'run2-arg2' 'run2-arg3'+
+ You choose the CGI type if you want the user to interact with the
+ $_GET
+ ,
+ $_POST
+ ,
+ $_SERVER
+ , etc super globals.
+
This exercise type allows to simulate a real HTTP request to a PHP script.
+ +
+ getRequests
+ should return an array of PSR-7 Requests. The number of requests is not limited. Check
+ here
+ for more information about PSR-7. The method is currently type hinted to return an array of the
+ Psr\Http\Message\RequestInterface
+ interface. You will need to use a library which implements the PSR-7 standard. Currently
+ zendframework/zend-diactoros
+ is shipped with
+ php-school/php-workshop
+ so you can just use that, or feel free to pull in a different implementation.
+
+<?php
+
+namespace PhpSchool\MyWorkshop\Exercise;
+
+use PhpSchool\PhpWorkshop\Exercise\AbstractExercise;
+use PhpSchool\PhpWorkshop\Exercise\CgiExercise;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseType;
+use Psr\Http\Message\RequestInterface;
+use Zend\Diactoros\Request;
+
+class MyExercise extends AbstractExercise implements ExerciseInterface, CgiExercise
+{
+ ...snip
+
+ **
+ * @return RequestInterface[]
+ */
+ public function getRequests()
+ {
+ return [
+ (new Request('http://www.my-library.com?genre=punk&limit=50'))
+ ->withMethod('GET')
+
+ ];
+ }
+
+ /**
+ * @return ExerciseType
+ */
+ public function getType()
+ {
+ return ExerciseType::CGI();
+ }
+}
+
+ The request above will result in PHP super globals looking something like this:
+
+//$_SERVER
+array(13) {
+ ["HTTP_HOST"] => string(18) "www.my-library.com"
+ ["SCRIPT_FILENAME"] => string(57) "/root/path/your-workshop/get-solution.php"
+ ["PWD"] => string(40) "/root/path/your-workshop"
+ ["REDIRECT_STATUS"] => string(3) "302"
+ ["SHLVL"] => string(1) "1"
+ ["CONTENT_LENGTH"] => string(1) "0"
+ ["QUERY_STRING"] => string(19) "genre=punk&limit=50"
+ ["REQUEST_METHOD"] => string(3) "GET"
+ ["_"] => string(48) "/usr/local/bin/php-cgi"
+ ["__CF_USER_TEXT_ENCODING"] => string(13) "0x1F5:0x0:0x0"
+ ["PHP_SELF"] => string(0) ""
+ ["REQUEST_TIME_FLOAT"] => float(1458634262.4929)
+ ["REQUEST_TIME"] => int(1458634262)
+}
+
+//$_GET
+array(2) {
+ ["genre"] => string(4) "punk"
+ ["limit"] => string(2) "50"
+}
+
+<?php
+
+namespace PhpSchool\MyWorkshop\Exercise;
+
+use PhpSchool\PhpWorkshop\Exercise\AbstractExercise;
+use PhpSchool\PhpWorkshop\Exercise\CgiExercise;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseType;
+use Psr\Http\Message\RequestInterface;
+use Zend\Diactoros\Request;
+
+class MyExercise extends AbstractExercise implements ExerciseInterface, CgiExercise
+{
+ ...snip
+
+ /**
+ * @return RequestInterface[]
+ */
+ public function getRequests()
+ {
+ $request1 = (new Request('http://www.my-library.com?genre=punk&limit=50'))
+ ->withMethod('POST')
+ ->withHeader('Content-Type', 'application/x-www-form-urlencoded');
+
+ $request1->getBody()->write(urlencode(['genre' => 'punk', 'limit' => 200]));
+ return [$request1];
+ }
+
+ /**
+ * @return ExerciseType
+ */
+ public function getType()
+ {
+ return ExerciseType::CGI();
+ }
+}
+ The request above will result in PHP super globals looking something like this:
+
+//$_SERVER
+ array(14) {
+ ["HTTP_HOST"] => string(18) "www.my-library.com"
+ ["SCRIPT_FILENAME"] => string(58) "/root/path/your-workshop/get-post-solution.php"
+ ["HTTP_CONTENT_TYPE"] => string(33) "application/x-www-form-urlencoded"
+ ["PWD"] => string(40) "/root/path/your-workshop"
+ ["REDIRECT_STATUS"] => string(3) "302"
+ ["SHLVL"] => string(1) "1"
+ ["CONTENT_LENGTH"] => string(2) "17"
+ ["CONTENT_TYPE"] => string(33) "application/x-www-form-urlencoded"
+ ["REQUEST_METHOD"] => string(4) "POST"
+ ["_"] => string(48) "/usr/local/bin/php-cgi"
+ ["__CF_USER_TEXT_ENCODING"] => string(13) "0x1F5:0x0:0x0"
+ ["PHP_SELF"] => string(0) ""
+ ["REQUEST_TIME_FLOAT"] => double(1458845731.5657)
+ ["REQUEST_TIME"] => int(1458845731)
+ }
+
+ //$_POST
+ array(2) {
+ ["genre"] => string(4) "punk"
+ ["limit"] => string(2) "50"
+ }
+
+ We use the
+ php-cgi
+ binary to setup the PHP environment. The
+ php-cgi
+ binary uses environment variables and standard input to configure itself, we export these variables based on the information you provide in
+ getRequests
+ .
+
getRequests
+ .
+ + You choose the CUSTOM type if you want the user to perform some arbitrary task that does not involve writing PHP code. For example the exercise could be to ask the user to download and install a + program and then we could check that it is running. +
+This exercise type allows to simulate a real HTTP request to a PHP script.
+ +
+ This is where you perform your verification. The method needs to return an instance of
+ PhpSchool\PhpWorkshop\Result\ResultInterface
+ . Depending on whether you want a success or failure to be recorded it will be an instance of
+ PhpSchool\PhpWorkshop\Result\SuccessInterface
+ or
+ PhpSchool\PhpWorkshop\Result\FailureInterface
+ . Learn about results
+
+<?php
+
+namespace PhpSchool\MyWorkshop\Exercise;
+
+use PhpSchool\PhpWorkshop\Exercise\AbstractExercise;
+use PhpSchool\PhpWorkshop\Exercise\CustomVerifyingExercise;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseType;
+use PhpSchool\PhpWorkshop\Result\Success;
+use PhpSchool\PhpWorkshop\Result\Failure;
+
+class MyExercise extends AbstractExercise implements ExerciseInterface, CustomVerifyingExercise
+{
+ ...snip
+
+ /**
+ * @return ResultInterface
+ */
+ public function verify()
+ {
+ if (//program is running) {
+ return new Success('My Program Check');
+ }
+ return new Failure('My Program Check', 'Program is running!');
+ }
+
+ /**
+ * @return ExerciseType
+ */
+ public function getType()
+ {
+ return ExerciseType::CGI();
+ }
+}
+ When a student runs the verify command your verify method will be invoked and the results will be displayed to the student!
+ diff --git a/assets/components/Website/Docs/Sections/Reference/PatchingExerciseSolutions.vue b/assets/components/Website/Docs/Sections/Reference/PatchingExerciseSolutions.vue new file mode 100644 index 00000000..48830c9b --- /dev/null +++ b/assets/components/Website/Docs/Sections/Reference/PatchingExerciseSolutions.vue @@ -0,0 +1,269 @@ + + ++ This is the fun bit! - In this article we will show how we can modify the student's solution, injecting, modifying and wrapping code. Before we get down to it, a little background to explain why + we built this feature. +
+ +
+ We wanted a way to make sure that
+ display_errors
+ and
+ error_reporting
+ were always configured correctly, we also thought that we might want to wrap solutions in
+ try/catch
+ blocks so we could give more structured feedback to the student. We figured, in order to do this in a robust manner, we would have to patch the student's solution on the fly and revert the changes
+ after the framework has verified/run the solution.
+
+ We decided this feature may be useful for workshop developers, we thought there may be possibilities where you want to concentrate on a verify specific problem like "Here is a variable - transform + it to this", well with this feature, you could inject that variable at the start of the script so it is already available to the student! +
+ +There are two type of modifications you can do to a solution:
+
+ In order to inform the workshop framework that an exercise would like to patch a solution it must implement
+ PhpSchool\PhpWorkshop\Exercise\SubmissionPatchable
+ and return an instance of
+ PhpSchool\PhpWorkshop\Patch
+ .
+
+ The
+ Patch
+ object is where you specify your
+ Insertions
+ &
+ Transformers
+ . The API looks like this:
+
+<?php + +use PhpSchool\PhpWorkshop\Patch; + +$patch = new Patch; +$patch = $patch->withInsertion($insertion1); +$patch = $patch->withInsertion($insertion2); +$patch = $patch->withTransformer($transformer); ++
Patch
+ class is immutable so you will need to assign the result of any calls to
+ with*
+ methods.
+ Insertions allow to insert a block of code at either the beginning or end of the student's solution. The API is very simple:
+ ++<?php + +use PhpSchool\PhpWorkshop\CodeInsertion; + +$before = new CodeInsertion(CodeInsertion::TYPE_BEFORE, 'echo "Before";'); +$after = new CodeInsertion(CodeInsertion::TYPE_AFTER, 'echo "After";'); ++
+ Transformers allow to modify the whole solution via an AST. A transformer is any valid PHP
+ callable
+ that returns an
+ array
+ of
+ PhpParser\Node
+ objects. The callable will be passed an
+ array
+ of
+ PhpParser\Node
+ objects which represent the parsed student's solution.
+
+ Lets see how you can build a transformer that wraps the solution in a
+ try/catch
+ block that then outputs the exception message.
+
+<?php
+
+use PhpSchool\PhpWorkshop\Exercise\SubmissionPatchable;
+use PhpSchool\PhpWorkshop\Patch;
+use PhpSchool\PhpWorkshop\CodeInsertion;
+use PhpParser\Node\Expr\MethodCall;
+use PhpParser\Node\Expr\Variable;
+use PhpParser\Node\Name;
+use PhpParser\Node\Stmt\Catch_;
+use PhpParser\Node\Stmt\Echo_;
+use PhpParser\Node\Stmt\TryCatch;
+
+class MyExercise extends AbstractExercise implements
+ ExerciseInterface,
+ SubmissionPatchable
+{
+ ...snip
+
+ /**
+ * @return Patch
+ */
+ public function getPatch()
+ {
+ $wrapInTryCatch = function (array $statements) {
+ return [
+ new TryCatch(
+ $statements,
+ [
+ new Catch_(
+ new Name('Exception'),
+ 'e',
+ [
+ new Echo_([
+ new MethodCall(new Variable('e'), 'getMessage')
+ ])
+ ]
+ )
+ ]
+ )
+ ];
+ };
+
+ return (new Patch)
+ ->withTransformer($wrapInTryCatch);
+ }
+}
+
+ + Note that the AST modification is fairly complicated, the feature is provided by the + nikic/php-parser + library and you should refer to that project for documentation on the AST. +
+ +
+ Let's write a patch that will wrap the solution in a
+ try/catch
+ block, add
+ echo 'Start';
+ at the beginning and add
+ echo 'Finish';
+ at the end:
+
+<?php
+
+use PhpParser\Node\Expr\MethodCall;
+use PhpParser\Node\Expr\Variable;
+use PhpParser\Node\Name;
+use PhpParser\Node\Stmt\Catch_;
+use PhpParser\Node\Stmt\Echo_;
+use PhpParser\Node\Stmt\TryCatch;
+use PhpSchool\PhpWorkshop\CodeInsertion;
+use PhpSchool\PhpWorkshop\Exercise\AbstractExercise;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface;
+use PhpSchool\PhpWorkshop\Exercise\SubmissionPatchable;
+use PhpSchool\PhpWorkshop\Patch;
+
+class MyExercise extends AbstractExercise implements
+ ExerciseInterface,
+ SubmissionPatchable
+{
+ ...snip
+
+ /**
+ * @return Patch
+ */
+ public function getPatch()
+ {
+ $wrapInTryCatch = function (array $statements) {
+ return [
+ new TryCatch(
+ $statements,
+ [
+ new Catch_(
+ new Name('Exception'),
+ 'e',
+ [
+ new Echo_([
+ new MethodCall(new Variable('e'), 'getMessage')
+ ])
+ ]
+ )
+ ]
+ )
+ ];
+ };
+
+ return (new Patch)
+ ->withTransformer($wrapInTryCatch)
+ ->withInsertion(new CodeInsertion(CodeInsertion::TYPE_BEFORE, "echo 'Start';"))
+ ->withInsertion(new CodeInsertion(CodeInsertion::TYPE_AFTER, "echo 'Finish';"));
+ }
+}
+
+ If the following solution was submitted:
+ +
+<?php
+
+echo "Hello World"
+throw new InvalidArgumentException('What is this magic?');
+
+ Then the code that is actually invoked by the workshop framework would be the following:
+ +
+<?php
+
+echo 'Start';
+try {
+ echo "Hello World"
+ throw new InvalidArgumentException('What is this magic?');
+} catch (Exception $e) {
+ echo $e->getMessage();
+}
+echo 'Finish';
+
+ In this section you will learn about the internals of the workshop application which will enable you to build robust and advanced workshops.
+You can use this section as a useful reference to refer back to during the development of your workshop.
+
+ For a walk through approach detailing the initial setup of a workshop and the development of your first exercise, check out the
+
The workshop utilises a container to build and publish services. Learn how to register new services and replace existing ones.
+ +Learn about the services exposed by the container, many of them utilities you can use in your exercises, checks and other services.
+ +
+ Exercises can be of either
+ CLI
+ or
+ CGI
+ type. Learn about the differences here and which one is right for your exercise.
+
Exercise solutions can consist of one or many files, which could consist of an entire application. They can also contain Composer dependencies in order to model real-world scenarios.
+ +Learn about Results and Result Renderers.
+ +Learn what Exercise Checks are and how to use them in your exercise.
+ +Learn about the available Exercise Checks in the workshop framework and how to use them.
+ +Learn how to create your own Simple Check.
+ +Create a custom result to use in your check.
+ +Create a renderer for your result.
+ +Learn about all the events available for listening to in the workshop framework and how to listen to them.
+ +Learn how to utilise events to build a Listener Check by following a tutorial to build a Couch DB check.
+ +Add simple checking logic directly in your exercise.
+ +Listen to events directly in your exercise instead of building a custom check.
+ +
+ Learn how to modify the student's solution on the fly, injecting variables, wrapping code in in
+ try/catch
+ blocks, registering error handlers and so on.
+
+ As we have seen in the previous articles, you can build your own custom checks. Checks can be used in as many exercises as you want - you could even create a package which consists of common
+ checks you might want to use in your workshops. The check we built in the
+
But what if you want to perform a check that you don't think you will use again? You don't really want to create a class to encompass this logic when it is only to be used in one exercise.
+Enter the Self Checking feature!
+ +
+ The Self Checking feature allows your exercise to implement an interface which contains one method -
+ check()
+ during the verification process of the student's solution, your method will be called and passed the input arguments passed to our workshop, which will contain the file name of the student's
+ solution. In this method you can do whatever you want: parse the code into an AST using the
+ PhpParser\Parser
+ service, lint it using a third party tool or whatever else you can think of.
+
+ To give you an example of how you might use it - we use it
+ here in Learn You PHP!
+ to check that a submission contains an
+ include
+ /
+ require
+ statement as the exercise is teaching how to separate code over multiple files. We want to enforce the student to include a separate file.
+
+ Creating a self checking exercise requires implementing the interface
+ PhpSchool\PhpWorkshop\ExerciseCheck\SelfCheck
+ , adding your check logic and returning a
+ PhpSchool\PhpWorkshop\Result\SuccessInterface
+ or
+ \PhpSchool\PhpWorkshop\Result\FailureInterface
+ .
+
The interface looks like this:
+
+<?php
+
+namespace PhpSchool\PhpWorkshop\ExerciseCheck;
+
+use PhpSchool\PhpWorkshop\Result\ResultInterface;
+use PhpSchool\PhpWorkshop\Input\Input;
+
+interface SelfCheck
+{
+ /**
+ * The method is passed the absolute file path to the student's solution and should return a result
+ * object which indicates the success or not of the check.
+ *
+ * @param Input $input The command line arguments passed to the command.
+ * @return ResultInterface The result of the check.
+ */
+ public function check(Input $input);
+}
+
+ You can implement like so:
+
+<?php
+
+class Mean extends AbstractExercise implements ExerciseInterface, CliExercise, SelfCheck
+{
+
+ ...omitting methods described in ExerciseInterface
+
+ /**
+ * @param Input $input
+ * @return ResultInterface
+ */
+ public function check(Input $input)
+ {
+ //do some checking with $input
+
+ if ($someResult) {
+ return new Success('My Check');
+ }
+
+ return new Failure('My Check', "Something didn't go well!");
+ }
+}
+
+
+ As you can see, you do the checking logic and then return a result object. The result object is used to render the results to the student. In this case the first argument to
+ PhpSchool\PhpWorkshop\Result\Success
+ is the name of the check being performed. The same is true for the failure
+ PhpSchool\PhpWorkshop\Result\Failure
+ , however, it takes an optional second argument which should describe what went wrong.
+
+ Contrary to what we said earlier (a PSR2 check would be a good candidate for a re-usable check), let's build that as a self check. We will use the already built example workshop as a base - the
+ finished code is available on the
+ self-checking-exercise
+ branch of the
+ tutorial repository
+ .
+
+ We will start fresh from the
+ master
+ branch for this tutorial, so if you haven't already got it, git clone it and install the dependencies:
+
+ Our check will run the + PHP_CodeSniffer + tool against the student's solution and report a success or failure based on the result. +
+ +Our exercise should look like the following:
+ +
+<?php
+
+namespace PhpSchool\SimpleMath\Exercise;
+
+use PhpSchool\PhpWorkshop\Exercise\AbstractExercise;
+use PhpSchool\PhpWorkshop\Exercise\CliExercise;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseType;
+use PhpSchool\PhpWorkshop\ExerciseCheck\SelfCheck;
+use PhpSchool\PhpWorkshop\Input\Input;
+use PhpSchool\PhpWorkshop\Result\Failure;
+use PhpSchool\PhpWorkshop\Result\ResultInterface;
+use PhpSchool\PhpWorkshop\Result\Success;
+
+class Mean extends AbstractExercise implements
+ ExerciseInterface,
+ CliExercise,
+ SelfCheck
+{
+
+ /**
+ * @return string
+ */
+ public function getName()
+ {
+ return 'Mean Average';
+ }
+
+ /**
+ * @return string
+ */
+ public function getDescription()
+ {
+ return 'Simple Math';
+ }
+
+ /**
+ * @return array
+ */
+ public function getArgs()
+ {
+ $numArgs = rand(0, 10);
+
+ $args = [];
+ for ($i = 0; $i < $numArgs; $i ++) {
+ $args[] = rand(0, 100);
+ }
+
+ return $args;
+ }
+
+ /**
+ * @return ExerciseType
+ */
+ public function getType()
+ {
+ return ExerciseType::CLI();
+ }
+
+ /**
+ * @param Input $input
+ * @return ResultInterface
+ */
+ public function check(Input $input)
+ {
+
+ }
+}
+
+
+ As you can see, our check does nothing at the minute. Let's add the logic to execute
+ phpcs
+ on the student's solution using the
+ PSR2
+ standard. As we brought in the tool via Composer, we can rest assured that the binary
+ phpcs
+ is available in our projects
+ vendor
+ directory.
+
Our method might look something like this - nothing new going on:
+ +
+/**
+ * @param Input $input
+ * @return ResultInterface
+ */
+public function check(Input $input)
+{
+ $phpCsBinary = __DIR__ . '/../../vendor/bin/phpcs';
+ $cmd = sprintf('%s %s --standard=PSR2', $phpCsBinary, $input->getArgument('program'));
+ exec($cmd, $output, $exitCode);
+
+ if ($exitCode === 0) {
+ return new Success('PSR2 Code Check');
+ }
+
+ return new Failure('PSR2 Code Check', 'Coding style did not conform to PSR2!');
+}
+
+
+ If the
+ phpcs
+ binary returns a non-zero exit code - a failure occurred: probably the solution did not pass the coding standard check. So we return a failure with an error message. Otherwise a Success is
+ returned.
+
+ Verifying a solution which does not pass the
+ PSR2
+ coding standard will yield the output:
+
+ + And a solution which + does + pass would yield the output: +
+ +
+
+ Hopefully this feature will help you build your workshops that bit faster!
+ diff --git a/assets/components/Website/Docs/Sections/Reference/SimpleChecks.vue b/assets/components/Website/Docs/Sections/Reference/SimpleChecks.vue new file mode 100644 index 00000000..db8b304a --- /dev/null +++ b/assets/components/Website/Docs/Sections/Reference/SimpleChecks.vue @@ -0,0 +1,574 @@ + + +
+ In this article we will learn how to create a simple check. If you don't fully understand what checks are, head over to the
+
We will build a fairly boring check which verifies that a student's solution passes the PSR2 coding standard. Lets get started!
+ +
+ Creating a check begins with creating a file and a class for our check . We need to implement the interface
+ PhpSchool\PhpWorkshop\Check\SimpleCheckInterface
+ which extends from
+ PhpSchool\PhpWorkshop\Check\CheckInterface
+ . Let's breakdown these methods before we start coding:
+
+ This method should just return a
+ string
+ which represents the name of the check. This will be printed on the terminal during the verification process. This will be
+ PSR2 Code Check
+ for our check.
+
+ This method should just return a
+ string
+ which is the FQCN (Fully Qualified Class Name) of the interface that the exercise needs to implement when requiring our check. Because we don't need any extra information for our check we can just
+ use
+ PhpSchool\PhpWorkshop\Exercise\ExerciseInterface
+ .
+
+ This method receives an
+ ExerciseType
+ instance which represents the type of exercise, we use this to inform the workshop which exercise types our check works with:
+ CLI
+ ,
+ CGI
+ or
+ CUSTOM
+ . We will support
+ CLI
+ &
+ CGI
+ .
+
+ This is the method where we actually perform our check logic, executing PHP_CodeSniffer. This method receives an instance of the current exercise and the input arguments passed to our workshop, + which will contain the file name of the student's solution. +
+ +
+ This method needs to return an instance of
+ PhpSchool\PhpWorkshop\Result\ResultInterface
+ . Depending on whether you want a success or failure to be recorded it will be an instance of
+ PhpSchool\PhpWorkshop\Result\SuccessInterface
+ or
+ PhpSchool\PhpWorkshop\Result\FailureInterface
+ . Learn about results
+
+ This method should return one of two constants
+ PhpSchool\PhpWorkshop\Check\SimpleCheckInterface::CHECK_BEFORE
+ ,
+ PhpSchool\PhpWorkshop\Check\SimpleCheckInterface::CHECK_AFTER
+ . This value indicates when the check should be run.
+
The process of verifying a student's solution looks something like the following (pseudo code):
+ +
+//run before checks
+foreach ($beforeChecks as $check) {
+ $result = $check->check($exercise, $submissionFilePath);
+
+ if (!$result->isSuccessful()) {
+ return;
+ }
+}
+
+//compare output of student solution and reference solution
+$this->verifier->compareOutput($exercise);
+
+foreach ($afterChecks as $check) {
+ $result = $check->check($exercise, $submissionFilePath);
+ //store result
+}
+
+
+ When a check uses
+ CHECK_BEFORE
+ mode it is run before the output verification. The process is also short circuited if a check returns a failure. No more checks will be run and the output will not be compared.
+
+ When a check use
+ CHECK_AFTER
+ mode it is run after the output verification. This means that the check is run after the student's solution has been run. After checks are useful for verifying that something was actually
+ performed in the students submission, for example, inserting a row into the database.
+
+ Now - let's build it! We will use the already built tutorial workshop as a base - the finished code is available on the
+ custom-simple-check
+ branch of the
+ tutorial repository
+ . We will start fresh from the
+ master
+ branch for this tutorial, so if you haven't already got it, git clone it and install the dependencies:
+
+ Our check will run the + PHP_CodeSniffer + tool against the student's submission and report a success or failure based on the result. +
+ +
+<?php
+
+namespace PhpSchool\SimpleMath\Check;
+
+use PhpSchool\PhpWorkshop\Check\SimpleCheckInterface;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseType;
+use PhpSchool\PhpWorkshop\Result\Failure;
+use PhpSchool\PhpWorkshop\Result\Success;
+use PhpSchool\PhpWorkshop\Input\Input;
+
+class Psr2Check implements SimpleCheckInterface
+{
+
+ public function getName()
+ {
+ return 'PSR2 Code Check';
+ }
+
+ public function getExerciseInterface()
+ {
+ return ExerciseInterface::class;
+ }
+
+ public function canRun(ExerciseType $exerciseType)
+ {
+ return in_array($exerciseType->getValue(), [ExerciseType::CGI, ExerciseType::CLI]);
+ }
+
+ public function check(ExerciseInterface $exercise, Input $input)
+ {
+ $phpCsBinary = __DIR__ . '/../../vendor/bin/phpcs';
+ $cmd = sprintf('%s %s --standard=PSR2', $phpCsBinary, $input->getArgument('program'));
+ exec($cmd, $output, $exitCode);
+
+ if ($exitCode === 0) {
+ return new Success($this->getName());
+ }
+
+ return new Failure($this->getName(), 'Coding style did not conform to PSR2!');
+ }
+
+ public function getPosition()
+ {
+ return static::CHECK_BEFORE;
+ }
+}
+
+
+ If the
+ phpcs
+ binary returns a non-zero exit code - a failure occurred: probably the solution did not pass the coding standard check. So we return a failure with an error message. Otherwise a Success is
+ returned.
+
phpcs
+ is available in our projects
+ vendor
+ directory.
+
+ Now you need to tell the application about your new check. We need to register a factory.
+ app/config.php
+ and add an entry for your check. The resulting file should look like:
+
+<?php + +use function DI\factory; +use function DI\object; +use Interop\Container\ContainerInterface; +use PhpSchool\SimpleMath\Check\Psr2Check; +use PhpSchool\SimpleMath\Exercise\Mean; +use Symfony\Component\Filesystem\Filesystem; + +return [ + //Define your exercise factories here + Mean::class => object(), + + //my checks + Psr2Check::class => object(), +]; ++
+ Note the new entry for
+ Psr2Check::class => object(),
+ . Finally, we need to tell the application about our check in
+ app/bootstrap.php
+ . After the application object is created you just call
+ addCheck
+ with the name of check class. Your final
+ app/bootstrap.php
+ file should look something like:
+
+<?php
+
+ini_set('display_errors', 1);
+date_default_timezone_set('Europe/London');
+switch (true) {
+ case (file_exists(__DIR__ . '/../vendor/autoload.php')):
+ // Installed standalone
+ require __DIR__ . '/../vendor/autoload.php';
+ break;
+ case (file_exists(__DIR__ . '/../../../autoload.php')):
+ // Installed as a Composer dependency
+ require __DIR__ . '/../../../autoload.php';
+ break;
+ case (file_exists('vendor/autoload.php')):
+ // As a Composer dependency, relative to CWD
+ require 'vendor/autoload.php';
+ break;
+ default:
+ throw new RuntimeException('Unable to locate Composer autoloader; please run "composer install".');
+}
+
+use PhpSchool\PhpWorkshop\Application;
+use PhpSchool\SimpleMath\Check\Psr2Check;
+use PhpSchool\SimpleMath\Exercise\Mean;
+
+$app = new Application('Simple Math', __DIR__ . '/config.php');
+
+$app->addExercise(Mean::class);
+$app->addCheck(Psr2Check::class);
+
+$art =<<<ART
+ ∞ ÷ ∑ ×
+
+ PHP SCHOOL
+SIMPLE MATH
+ART;
+
+$app->setLogo($art);
+$app->setFgColour('red');
+$app->setBgColour('black');
+
+return $app;
+
+
+ Open up the Mean Average exercise file:
+ src/Exercise/Mean.php
+ and add in the following method, take care to import the necessary classes (
+ PhpSchool\PhpWorkshop\ExerciseDispatcher
+ &
+ PhpSchool\SimpleMath\Check\Psr2Check
+ ):
+
+public function configure(ExerciseDispatcher $dispatcher)
+{
+ $dispatcher->requireCheck(Psr2Check::class);
+}
+
+ Hopefully you will remember this from the previous section - we are just telling the exercise to use our custom check!
+ +
+ Run the workshop and select the Mean Average exercise. Verifying a solution which does not pass the
+ PSR2
+ coding standard will yield the output:
+
+
+ + And a solution which + does + pass will yield the output: +
+ +
+
+
+
+ When you build checks, sometimes you need extra information from the exercise to configure the check. For example, the
+ FunctionRequirementsCheck
+ check calls
+ getRequiredFunctions()
+ &
+ getBannedFunctions()
+ on the exercise, these methods are defined on the extra interface
+ FunctionRequirementsExerciseCheck
+ which the exercise must implement if it requires the
+ FunctionRequirementsCheck
+ check.
+
+ Maybe we want to make the standard for our check configurable - it could be
+ PSR1
+ ,
+ PSR2
+ ,
+ PEAR
+ or any of the other standards PHP_CodeSniffer supports. We will make this configuration available through the method
+ getStandard()
+ .
+
We need an interface to define our required method. Let's do that first:
+ +
+ Now would probably be a good idea to change our check name to something a little less specific, but we'll leave that up to you, probably
+ PhpCsCheck
+ might be a little better. Okay, lets define our interface. We want the one method
+ getStandard
+ to return a string representing one of the
+ available standards
+ :
+
+<?php
+
+namespace PhpSchool\SimpleMath\ExerciseCheck;
+
+interface Psr2ExerciseCheck
+{
+ /**
+ * @return string
+ */
+ public function getStandard();
+}
+
+
+ We need to update the
+ getExerciseInterface()
+ method in our check to return the name of our new interface. Open up
+ src/Check/Psr2Check.php
+ and change the
+ getExerciseInterface()
+ method to match below:
+
+public function getExerciseInterface()
+{
+ return Psr2ExerciseCheck::class;
+}
+
+
+ We also need to modify our
+ check()
+ method to actually use this data:
+
+public function check(ExerciseInterface $exercise, $fileName)
+{
+ if (!$exercise instanceof Psr2ExerciseCheck) {
+ throw new \InvalidArgumentException;
+ }
+
+ $standard = $exercise->getStandard();
+
+ if (!in_array($standard, ['PSR1', 'PSR2', 'PEAR'])) {
+ throw new \InvalidArgumentException('Standard is not supported');
+ }
+
+ $phpCsBinary = __DIR__ . '/../../vendor/bin/phpcs';
+ $cmd = sprintf('%s %s --standard=%s', $phpCsBinary, $input->getArgument('program'), $standard);
+ exec($cmd, $output, $exitCode);
+
+ if ($exitCode === 0) {
+ return new Success($this->getName());
+ }
+
+ return new Failure($this->getName(), 'Coding style did not conform to PSR2!');
+}
+
+
+ We've added a couple of things here - we make sure the exercise actually implements our required interface, if not we throw an exception. We check if the standard provided is in a small subset of
+ supported standards, and finally, we pass the standard along to the
+ phpcs
+ command.
+
+ Now we have to implement the new interface and methods in our exercise, for our Mean Average exercise, we will still require
+ PSR2
+ as the coding standard. The final exercise should look similar to below:
+
+<?php
+
+namespace PhpSchool\SimpleMath\Exercise;
+
+use PhpSchool\PhpWorkshop\Exercise\AbstractExercise;
+use PhpSchool\PhpWorkshop\Exercise\CliExercise;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseType;
+use PhpSchool\PhpWorkshop\ExerciseDispatcher;
+use PhpSchool\SimpleMath\Check\Psr2Check;
+use PhpSchool\SimpleMath\ExerciseCheck\Psr2ExerciseCheck;
+
+class Mean extends AbstractExercise implements
+ ExerciseInterface,
+ CliExercise,
+ Psr2ExerciseCheck
+{
+
+ /**
+ * @return string
+ */
+ public function getName()
+ {
+ return 'Mean Average';
+ }
+
+ /**
+ * @return string
+ */
+ public function getDescription()
+ {
+ return 'Simple Math';
+ }
+
+ /**
+ * @return array
+ */
+ public function getArgs()
+ {
+ $numArgs = rand(0, 10);
+
+ $args = [];
+ for ($i = 0; $i < $numArgs; $i ++) {
+ $args[] = rand(0, 100);
+ }
+
+ return $args;
+ }
+
+ /**
+ * @return ExerciseType
+ */
+ public function getType()
+ {
+ return ExerciseType::CLI();
+ }
+
+ public function configure(ExerciseDispatcher $dispatcher)
+ {
+ $dispatcher->requireCheck(Psr2Check::class);
+ }
+
+ /**
+ * @return string
+ */
+ public function getStandard()
+ {
+ return 'PSR2';
+ }
+}
+ You should be able to run it just the same as before we added the extra interface. You can now easily update your exercise to use a different coding standard without modifying the check.
+ +PSR2
+ hardcoded in the message!
+
+ You can see the finished, working code on the
+ custom-interface-check
+ branch of the
+ tutorial repository
+ .
+
+ Internally, the workshop framework uses a
+ dependency injection container
+ . This allows you to request other services from the application and replace services with your own implementations. We use the
+ PHP-DI
+ package for dependency injection, a
+ container-interop
+ compatible dependency injection container.
+
+ Services are configured in
+ app/config.php
+ . You can use all of the features of
+ PHP-DI
+ so check the docs there.
+
+ The file
+ app/config.php
+ should return an array of service definitions for the container. The key being the name of the service and the value the actual factory.
+
+ \DI\Object()
+ is a helper function to create a factory which will simply run
+ new $yourClassName
+ when asking for the service from the container.
+
+return [ + ...snip + Generator::class => \DI\Object(), +];+
+ PHP-DI provides more powerful features such as being able to use anonymous functions and any valid PHP
+ callables
+ as a factory. When using a callable your callable will be injected with the container itself which you can pull other services from!
+
+ If you pulled in a third party library for random number generation you might define a service like below. We use the battle tested package:
+ ircmaxell/random-lib
+ as an example.
+
We use an anonymous function and pull the strength parameter from the container and create a new random number generator based on that.
+
+return [
+ ...snip
+ \RandomLib\Generator::class => \DI\factory(function (ContainerInterface $container) {
+ $strength = $container->get('random-number-generator-strength');
+ $factory = new \RandomLib\Factory;
+ return $factory->getGenerator(new \SecurityLib\Strength($strength));
+ }),
+];
+
+ As your workshop configuration is merged into default workshop framework configuration, you can override existing services with your own implementation. Maybe you want to override the
+ \Symfony\Component\Filesystem\Filesystem
+ service with your own version, maybe you extended it to add some methods.
+
+ The below definition would replace the
+ Symfony\Component\Filesystem\Filesystem
+ service with your own implementation:
+ MyFileSystem
+ which extends from
+ \Symfony\Component\Filesystem\Filesystem
+ .
+
+return [
+ ...snip
+ Symfony\Component\Filesystem\Filesystem::class => \DI\factory(function (ContainerInterface $c) {
+ return new MyFileSystem;
+ }),
+];
+
+ Now when you ask the container for
+ Symfony\Component\Filesystem\Filesystem
+ you will receive an instance of
+ MyFileSystem
+ .
+
Symfony\Component\Filesystem\Filesystem
+ service will expect it to be an instance of
+ \Symfony\Component\Filesystem\Filesystem
+ , so if you replace it with an altogether different object, expect things to break!
+
+ Check
+
A workshop is a fairly useless without any exercises, so here we will learn how to create them.
+We will decide on a topic of basic PHP operators, more specifically working out the mean average of a given set of numbers.
+ +Given this specification we could write a program which would serve as our reference solution.
+
+<?php
+$count = 0;
+for ($i = 1; $i < count($argv); $i++) {
+ $count += $argv[$i];
+}
+
+$numberCount = count($argv) - 1;
+echo $count / $numberCount;
+
+ We should place this file in
+ exercises/mean-average/solution/solution.php
+
+ The next step is to create a problem file. A problem file contains the instructions for the exercise. It should be a markdown file. This file is rendered in the Terminal to the student when they + select the exercise. +
+ +Our problem file might look like the following.
+
+Write a program that accepts one or more numbers as command-line arguments and prints the mean average of those numbers to the console (stdout).
+
+----------------------------------------------------------------------
+## HINTS
+
+You can access command-line arguments via the global `$argv` array.
+
+To get started, write a program that simply contains:
+
+```php
+var_dump($argv);
+```
+
+Run it with `php program.php` and some numbers as arguments. e.g:
+
+```sh
+$ php program.php 1 2 3
+```
+
+In which case the output would be an array looking something like:
+
+```php
+array(4) {
+[0] =>
+string(7) "program.php"
+[1] =>
+string(1) "1"
+[2] =>
+string(1) "2"
+[3] =>
+string(1) "3"
+}
+```
+
+You'll need to think about how to loop through the number of arguments so you can output just their mean average. The first element of the `$argv` array is always the name of your script. eg `program.php`, so you need to start at the 2nd element (index 1), adding each item to the total until you reach the end of the array. You will then need to work out the average based on the amount of arguments given to you.
+
+You can read how to work out an average here:
+ [https://www.mathsisfun.com/mean.html]()
+
+Also be aware that all elements of `$argv` are strings and you may need to *coerce* them into numbers. You can do this by prefixing the property with a cast `(int)` or just adding them. PHP will coerce it for you.
+
+`{appname}` will be supplying arguments to your program when you run `{appname} verify program.php` so you don't need to supply them yourself. To test your program without verifying it, you can invoke it with `{appname} run program.php`. When you use `run`, you are invoking the test environment that `{appname}` sets up for each exercise.
+
+----------------------------------------------------------------------
+ {appname}
+ will be replaced with the actual application name, this will most likely be the configuration you set when creating your workshop as this is inferred from the command the student executed to run
+ the workshop.
+
+ Drop this file in
+ exercises/mean-average/problem/problem.md
+ .
+
Now we write the code, there is not much to it, this is a simple exercise!
+ +
+<?php
+
+namespace PhpSchool\SimpleMath\Exercise;
+
+use PhpSchool\PhpWorkshop\Exercise\AbstractExercise;
+use PhpSchool\PhpWorkshop\Exercise\CliExercise;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface;
+use PhpSchool\PhpWorkshop\Exercise\ExerciseType;
+
+class Mean extends AbstractExercise implements ExerciseInterface, CliExercise
+{
+
+ /**
+ * @return string
+ */
+ public function getName()
+ {
+ return 'Mean Average';
+ }
+
+ /**
+ * @return string
+ */
+ public function getDescription()
+ {
+ return 'Simple Math';
+ }
+
+ /**
+ * @return string[][]
+ */
+ public function getArgs()
+ {
+ $numArgs = rand(1, 10);
+
+ $args = [];
+ for ($i = 0; $i < $numArgs; $i ++) {
+ $args[] = rand(0, 100);
+ }
+
+ return [$args];
+ }
+
+ /**
+ * @return ExerciseType
+ */
+ public function getType()
+ {
+ return ExerciseType::CLI();
+ }
+}
+
+
+ Place the above in
+ src/Exercise/Mean.php
+ .
+
Now lets break this down.
+This class represents our exercise, it describes how the programs will be executed, the student's and our reference solution.
+ +
+ The
+ AbstractExercise
+ class implements a few interesting methods for us. Mainly
+ getSolution
+ and
+ getProblem
+ . These methods are responsible for locating your solution and problem files. By default they take your exercise's name, normalise it (remove anything that is not A-Za-z or a dash, lowercase and
+ replace spaces with dashes) and look in the
+ exercises/<normalised-name>/solution
+ and
+ exercises/<normalised-name>/problem
+ folders for files named
+ solution.php
+ and
+ problem.md
+ respectively. There maybe be cases when you need to override these methods, and in that case you probably don't need to extend from
+ AbstractExercise
+ .
+
getSolution
+ and
+ getProblem
+ if you want to organise your problems and solutions in a different structure. We would advise against this in the name of consistency but if you have a good enough reason then the option is there.
+ There may also be the case that your solution is not simply one file. Jump over to
+
+ Each exercise must have a type, there are currently two types of exercise:
+ CGI
+ ,
+ CLI
+ &
+ CUSTOM
+ . Head over to
+ CLI
+ type exercise, this means our reference solution and the student's solution programs will be invoked using the PHP CLI binary. The arguments will come from our exercise class. We inform the
+ workshop of our exercise type by returning an instance of
+ ExerciseType
+ from the
+ getType
+ method.
+ ExerciseType
+ is an ENUM. In conjunction with this, our exercise should implement the respective interface. For
+ CLI
+ type exercises this is
+ CliExercise
+ .
+
+ This interface defines one method:
+ getArgs
+ . This method should return an array of arrays containing string arguments which will be passed to our reference solution and the student's solution at runtime. Each set of arguments will be sent
+ to the solution. So you could essentially run the student's solution as many times as you wanted with different arguments. This method can return random records and random numbers of arguments so
+ that each time the student runs the verification process they receive different arguments. This makes sure the solution is robust.
+
getArgs
+ may return random arguments, your reference solution and the student's solution will always receive the same arguments as the
+ getArgs
+ method is only called once.
+ Our exercise simply returns one set of random number of arguments between 0 and 10, each being a random number between 0 and 100.
+ +
+ The remaining methods to implement are
+ getName
+ and
+ getDescription
+ .
+ getName
+ is the name of the exercise to be displayed in the menu and
+ getDescription
+ is a short description of the exercise. This is not actually used anywhere yet but is useful when glancing through the code.
+
+ Internally, the workshop application uses a + dependency injection container + . This allows you to request other services from the application and replace services with your own implementations. In order for the application to locate your exercise, you need to register it + with the application and also provide a factory for it. We use the + PHP-DI + package for dependency injection. +
+
+ First, lets create a factory for our exercise. Open up
+ app/config.php
+ .
+
+return [ + Mean::class => \DI\object(), +];+
+ The file
+ app/config.php
+ should return an array of service definitions for the container. The key being the name of the service and the value the actual factory. For the case of exercises the service name should
+ always
+ be the class name.
+ \DI\object()
+ is a helper function to create a factory which will simply run
+ new $yourClassName
+ when asking for the service from the container.
+
+ You are almost done! we have registered the factory which tells the application how to create your exercise. We just need to make it aware of your exercise. We do this in
+ app/bootstrap.php
+ . After the
+ Application
+ object is created you just call
+ addExercise
+ with the name of your exercise class. Your final
+ app/bootstrap.php
+ file should look something like the following:
+
+<?php
+ini_set('display_errors', 1);
+date_default_timezone_set('Europe/London');
+switch (true) {
+ case (file_exists(__DIR__ . '/../vendor/autoload.php')):
+ // Installed standalone
+ require __DIR__ . '/../vendor/autoload.php';
+ break;
+ case (file_exists(__DIR__ . '/../../../autoload.php')):
+ // Installed as a Composer dependency
+ require __DIR__ . '/../../../autoload.php';
+ break;
+ case (file_exists('vendor/autoload.php')):
+ // As a Composer dependency, relative to CWD
+ require 'vendor/autoload.php';
+ break;
+ default:
+ throw new RuntimeException('Unable to locate Composer autoloader; please run "composer install".');
+}
+
+use PhpSchool\PhpWorkshop\Application;
+use PhpSchool\SimpleMath\Exercise\Mean;
+
+$app = new Application('Simple Math', __DIR__ . '/config.php');
+
+$app->addExercise(Mean::class);
+
+$art = <<<ART
+ ∞ ÷ ∑ ×
+
+ PHP SCHOOL
+SIMPLE MATH
+ART;
+
+$app->setLogo($art);
+$app->setFgColour('red');
+$app->setBgColour('black');
+
+return $app;
+
+ That's it! You should now see your exercise in the menu when you run the app.
+ +
+
+ + You can compare your workshop against + https://github.com/php-school/simple-math + . This is a working copy of the tutorial workshop. +
+ diff --git a/assets/components/Website/Docs/Sections/Tutorial/CreatingYourOwnWorkshop.vue b/assets/components/Website/Docs/Sections/Tutorial/CreatingYourOwnWorkshop.vue new file mode 100644 index 00000000..cb1436f3 --- /dev/null +++ b/assets/components/Website/Docs/Sections/Tutorial/CreatingYourOwnWorkshop.vue @@ -0,0 +1,123 @@ + + ++ In this tutorial we will be building a simple workshop which is available + here on GitHub + as a reference, if you have any problems, you can double check your work. +
+ ++ In order to create a workshop the first thing you will need to do is to setup a new project and configure the dependencies and wire it all together. Luckily for you we have created a starter kit + which does this all for you, prompting you along the way. Simply execute the following command in your shell. +
+ +
+ Where
+ <your-workshop-name>
+ is the name of the directory you want to setup your workshop in. For this tutorial we will create a workshop teaching some basic math concepts so we will use the name
+ simple-math
+ . You will be prompted for a few things:
+
+ The name for your workshop to be put in the
+ composer.json
+ file. This should follow the pattern
+ vendor/package
+ . In the case of our flagship workshop
+ Learn You PHP!
+ this is
+ php-school/learn-you-php
+ . Name your package
+ php-school/simple-math
+ .
+
+ More info on the
+ name
+ property
+ can be found in the composer documentation
+ .
+
+ A short description of your workshop which will be added to the
+ composer.json
+ file. Add a short description here, something like:
+ Learn the basics of math with PHP!
+
+ More info on the
+ description
+ property
+ can be found in the composer documentation
+ .
+
+ The namespace which will be used in the project so it can be added to the autoload configuration in the
+ composer.json
+ file. In the case of
+ Learn You PHP!
+ this is
+ PhpSchool\LearnYouPhp
+ . We will use
+ PhpSchool\SimpleMath
+ .
+
+ More info on the
+ autoload
+ property
+ can be found in the composer documentation
+ .
+
+ This is the actual command students will execute to run your workshop. In the case of
+ Learn You PHP!
+ this is
+ learnyouphp
+ . You should set this as
+ simple-math
+ .
+
After you have entered the required information the workshop dependencies will be downloaded and the autoload files will be generated.
+ +Generate the autoload information for our Namespace:
+
+ Where
+ <binary-name>
+ is the name your previously entered during the
+ create-project
+ command. In our case:
+
+
+
+
+ You can alter the colours, logo and text of your workshop very easily. Look in
+ app/bootstrap.php
+ and you should see the following code.
+
+$app->setLogo($art);
+$app->setFgColour('green');
+$app->setBgColour('black');
+
+ You can modify any of these settings. Available colours are black, red, green, yellow, blue, magenta, cyan, white.
+ setLogo
+ takes a
+ string
+ and can be used display ascii art!
+
+ You can set the title of your workshop which will be displayed on the terminal by changing the
+ null
+ argument to
+ Application
+ to a title of your choice. For example:
+
$app = new Application('My Workshop', __DIR__ . '/config.php');
+ We will customise the theme a little to be more representative of our subject:
+ +
+$art = <<<ART
+ ∞ ÷ ∑ ×
+
+ PHP SCHOOL
+SIMPLE MATH
+ART;
+
+$app->setLogo($art);
+$app->setFgColour('red');
+$app->setBgColour('black');
+
+
+
diff --git a/assets/components/Website/Docs/Sections/Tutorial/TutorialHome.vue b/assets/components/Website/Docs/Sections/Tutorial/TutorialHome.vue
new file mode 100644
index 00000000..0f73b035
--- /dev/null
+++ b/assets/components/Website/Docs/Sections/Tutorial/TutorialHome.vue
@@ -0,0 +1,29 @@
+
+
+ + So.. you want to create your own workshop? You want to teach a subject using the workshop environment? You want to make a meetup more hands on? You want to showcase your tool? You've come to the + right place! +
++ We will be building a simple workshop which is available + here on GitHub + as a reference. So if you have any problems, you can double check your work. +
+ +Learn how to create and setup your very own workshop.
+Customise the look and feel of your workshop, personalise to your brand or subject.
+Create the first exercise for your workshop.
+ diff --git a/assets/components/Website/Docs/contents.js b/assets/components/Website/Docs/contents.js new file mode 100644 index 00000000..7f92a0db --- /dev/null +++ b/assets/components/Website/Docs/contents.js @@ -0,0 +1,189 @@ +import DocHome from "./Sections/DocHome.vue"; +import TutorialHome from "./Sections/Tutorial/TutorialHome.vue"; +import CreatingYourOwnWorkshop from "./Sections/Tutorial/CreatingYourOwnWorkshop.vue"; +import ModifyTheme from "./Sections/Tutorial/ModifyTheme.vue"; +import CreatingAnExercise from "./Sections/Tutorial/CreatingAnExercise.vue"; +import ReferenceHome from "./Sections/Reference/ReferenceHome.vue"; +import TheContainer from "./Sections/Reference/TheContainer.vue"; +import AvailableServices from "./Sections/Reference/AvailableServices.vue"; +import ExerciseTypes from "./Sections/Reference/ExerciseTypes.vue"; +import ExerciseSolutions from "./Sections/Reference/ExerciseSolutions.vue"; +import Results from "./Sections/Reference/CheckResults.vue"; +import ExerciseChecks from "./Sections/Reference/ExerciseChecks.vue"; +import BundledChecks from "./Sections/Reference/BundledChecks.vue"; +import SimpleChecks from "./Sections/Reference/SimpleChecks.vue"; +import CreatingCustomResults from "./Sections/Reference/CreatingCustomResults.vue"; +import CreatingCustomResultRenderers from "./Sections/Reference/CreatingCustomResultRenderers.vue"; +import Events from "./Sections/Reference/EventSystem.vue"; +import CreatingListenerChecks from "./Sections/Reference/CreatingListenerChecks.vue"; +import SelfCheckingExercises from "./Sections/Reference/SelfCheckingExercises.vue"; +import ExerciseEvents from "./Sections/Reference/ExerciseEvents.vue"; +import PatchingExerciseSolutions from "./Sections/Reference/PatchingExerciseSolutions.vue"; + +const docs = [ + { + path: "", + title: "Documentation Home", + sections: [ + { + path: "", + title: "Documentation Home", + component: DocHome, + file: "DocHome.vue", + }, + ], + }, + { + path: "tutorial", + title: "Tutorial", + sections: [ + { + path: "", + title: "Workshop Tutorial", + component: TutorialHome, + file: "Tutorial/TutorialHome.vue", + }, + { + path: "creating-your-own-workshop", + title: "Creating your own workshop", + component: CreatingYourOwnWorkshop, + file: "Tutorial/CreatingYourOwnWorkshop.vue", + }, + { + path: "modify-theme", + title: "Modifying the theme of your workshop", + component: ModifyTheme, + file: "Tutorial/ModifyTheme.vue", + }, + { + path: "creating-an-exercise", + title: "Creating an exercise", + component: CreatingAnExercise, + file: "Tutorial/CreatingAnExercise.vue", + }, + ], + }, + { + path: "reference", + title: "Reference Documentation", + sections: [ + { + path: "", + title: "Reference Documentation", + component: ReferenceHome, + file: "Reference/ReferenceHome.vue", + }, + { + path: "container", + title: "The Container", + component: TheContainer, + file: "Reference/TheContainer.vue", + }, + { + path: "available-services", + title: "Available Services", + component: AvailableServices, + file: "Reference/AvailableServices.vue", + }, + { + path: "exercise-types", + title: "Exercise Types", + component: ExerciseTypes, + file: "Reference/ExerciseTypes.vue", + }, + { + path: "exercise-solutions", + title: "Exercise Solutions", + component: ExerciseSolutions, + file: "Reference/ExerciseSolutions.vue", + }, + { + path: "results", + title: "Results & Renderers", + component: Results, + file: "Reference/CheckResults.vue", + }, + { + path: "exercise-checks", + title: "Exercise Checks", + component: ExerciseChecks, + file: "Reference/ExerciseChecks.vue", + }, + { + path: "bundled-checks", + title: "Bundled Checks", + component: BundledChecks, + file: "Reference/BundledChecks.vue", + }, + { + path: "creating-simple-checks", + title: "Creating Simple Checks", + component: SimpleChecks, + file: "Reference/SimpleChecks.vue", + }, + { + path: "creating-custom-results", + title: "Creating Custom Results", + component: CreatingCustomResults, + file: "Reference/CreatingCustomResults.vue", + }, + { + path: "creating-custom-result-renderers", + title: "Creating Custom Result Renderers", + component: CreatingCustomResultRenderers, + file: "Reference/CreatingCustomResultRenderers.vue", + }, + { + path: "events", + title: "Events", + component: Events, + file: "Reference/ExerciseEvents.vue", + }, + { + path: "creating-listener-checks", + title: "Creating Listener Checks", + component: CreatingListenerChecks, + file: "Reference/CreatingListenerChecks.vue", + }, + { + path: "self-checking-exercises", + title: "Self Checking Exercises", + component: SelfCheckingExercises, + file: "Reference/SelfCheckingExercises.vue", + }, + { + path: "exercise-events", + title: "Exercise Events", + component: ExerciseEvents, + file: "Reference/ExerciseEvents.vue", + }, + { + path: "patching-exercise-solutions", + title: "Patching Exercise Submissions", + component: PatchingExerciseSolutions, + file: "Reference/PatchingExerciseSolutions.vue", + }, + ], + }, +]; + +const sectionRoute = (group, section) => { + const parts = ["docs", group.path, section.path]; + + return "/" + parts.filter((part) => part !== "").join("/"); +}; + +const docRoutes = (docs) => { + return [].concat( + ...docs.map((doc) => { + return doc.sections.map((section) => { + return { + path: sectionRoute(doc, section), + component: section.component, + }; + }); + }), + ); +}; + +export { docs as docs, sectionRoute as sectionRoute, docRoutes as docRoutes }; diff --git a/assets/components/Website/JoinSlack.vue b/assets/components/Website/JoinSlack.vue new file mode 100644 index 00000000..42cb7174 --- /dev/null +++ b/assets/components/Website/JoinSlack.vue @@ -0,0 +1,84 @@ + + + ++ You can find our community slack + here + . If you don't have an account, use the form below with your e-mail to get an invite. +
+ +Invite was sent, please check your e-mails.
+{{ inviteError }}
+ +
+
{{ line.title }}: {{ line.description }}
++ Congratulations! your solution passed. + Congratulations! your solution passed. +
+Write a program that prints the text "Hello World" to the console (stdout).
+
+ We've created you an empty file, look in the file tree for
+ solution.php
+ . That's your starting point.
+
+ We'll execute your solution file for you when you press the
+ Run
+ or
+ Verify
+ buttons. The
+ Run
+ button simply runs your program and captures the output, displaying it for you to view. The
+ Verify
+ button runs your program but performs some extra tasks, such as comparing the output, checking for certain structures and function calls, etc. It then displays the result of those
+ verifications.
+
+ Both
+ Run
+ and
+ Verify
+ execute your program with random inputs which are determined by the current exercise. For example one exercise might generate a bunch of numbers to pass to your program, where another one
+ might pass you a JSON encoded string.
+
You can write to the console from a PHP program with the following code:
+
+ + The first line tells PHP to interpret the code following it. It is required before any PHP code is written. The second line is the instruction to print out some text. +
+
+ Documentation on
+ PHP tags
+ can be found by pointing your browser here:
+
+ https://php.net/manual/en/language.basic-syntax.phptags.php
+
+ Try pressing the
+ Run
+ button in the bottom right corner to execute your program.
+
You should see the word "text" printed out on the console.
+Now you must adapt the code to pass the presented challenge. Remember, the challenge was: Write a program that prints the text "Hello World" to the console.
+
+ When you have finished editing your program you must verify it. Click the
+ Verify
+ button in the bottom right corner.
+
Your program will be tested, a report will be generated, and the lesson will be marked 'completed' if you are successful.
+Aydin Hassan
+11 out of 24
+File Exists Check
+Code Exists Check
+PHP Code Check
+Code Parse Check
+Output was incorrect
+ Show diff +Pick an exercise to start hacking!
+
+
+ Pick a workshop to try it out online!
++ We're sure you're eager to jump in and start coding, but you're gonna have to slow down a little. The first step to creating your own workshop is to read the documentation: +
++ Now it's time to build - you will create your base application, define a theme and a topic, design a set of exercises which progress in difficulty and introduce new and advanced topics to + the student. All in PHP. +
+
+ Once you've built your workshop, you
+ workshop-manager
+ and then later on, as part of PHP School Online!
+
+ Your workshop is available for the world to see, use and learn from. Not only that but it's open source, it's available for other developers to update, fix, improve the copy, translate and + so on! +
++ PHP School workshops are a set of exercises focused on a specific topic or overarching theme, such as new language versions or tools and techniques. See the + below section + for the list of available workshops. +
+ ++ Workshops can be run in your browser or you can download them and run them on your own computer. Running them on your own computer is a little bit more involved, however, it is also more + flexible and self contained. +
+ +Simply login or register with your GitHub account, head to the dashboard, select an exercise and go.
++ Not all exercises are available in the online IDE, for example if some workshops are not kept up to date they may get dropped. However, you will always be able to install them on your own + computer. +
++ You need to configure your system, install the Workshop Manager, and then install the workshops. Head on over to the + Running PHP School locally page to get the low down. +
+Naturally, you need to be comfortable in a terminal such as iTerm and know your way around a decent text editor or IDE.
+
+ + Workshops meticulously crafted by the PHP School team with the utmost quality to help + you + learn the core concepts of PHP and web development. +
+
+
+ Community workshops are those created by, well, you! They are not officially maintained by the PHP School team and not all of them are compatible with the online system. Compatible ones are
+ labeled, otherwise, you can always run them
+
+ Visit the
+
+
{{ description }}
+
+
+ {{ event.description }} +
++ More info: + {{ event.link }} +
+ + ++ {{ event.description }} +
++ More info: + {{ event.link }} +
+ + +
+ Test your solution at any time to get instant feedback. If you get it wrong, we’ll show you how and what went wrong versus what we expected. Maybe you just need a simple tweak, or to
+ altogether reconsider your approach. When you solve an exercise, your profile is updated and the exercise is marked as completed. Before proceeding you have the opportunity to see our
+ official solution so you can compare notes and optimise your own solution. Maybe your solution is better!
+
+
+ Try writing:
+ echo "Hello World";
+ in the input below to see how we verify your solution and provide feedback.
+
+ In order to get started with PHP School workshops, you first need to install the workshop manager. Before we can do this we need to check you have a few things: +
+You will need PHP with a version of at least 7.1 although we recommend using the latest available.
+You will also need a Text Editor so you can work through the workshops. You can try Atom or Sublime if you don't already have one.
+Once the above are satisfied, run the following commands in your terminal, to install the workshop manager.
+Common issues with Mac OSX installations include not having a new enough version of PHP and not having Composer available.
+If you have a PHP version less than 8.1, you will need to update it to at least 8.1, you can do so with the following commands
+ +php -v
+
+ After installing a workshop using the workshop manager you may find it's not available to run immediately. If this happens the simplest remedy is to make sure PHP School's workshop
+ bin directory is available in the
+ $PATH
+ environment variable. You can check this with
+ workshop-manager
+ verify which will also provide the relevant details on how to resolve the issue. To learn more about the
+ $PATH
+ environment, click
+ here
+ .
+
Common issues with Linux installations include not having a new enough version of PHP and not having Composer available.
+If you have a PHP version less than 8.1, you will need to update it to at least 8.1, you can do so with the following commands
+ +php -v
+
+ After installing a workshop using the workshop manager you may find it's not available to run immediately. If this happens the simplest remedy is to make sure PHP School's workshop
+ bin directory is available in the
+ $PATH
+ environment variable. You can check this with
+ workshop-manager
+ verify which will also provide the relevant details on how to resolve the issue. To learn more about the
+ $PATH
+ environment, click
+ here
+ .
+
+ Windows is a difficult system to cater for in the PHP world. Unfortunately, it has various differences on the command line and console emulators which PHP unfortunately doesn't + support. The best way to get PHP School Workshops running is to install Cygwin + ConEmu. Once the initial setup of these are complete, the process of installing workshops is the same + as Linux and Mac OSX operating systems. +
+If not, follow the instructions below:
+
+ After installing a workshop using the workshop manager you may find it's not available to run immediately. If this happens the simplest remedy is to make sure PHP School's workshop
+ bin directory is available in the
+ $PATH
+ environment variable. You can check this with
+ workshop-manager
+ verify which will also provide the relevant details on how to resolve the issue. To learn more about the
+ $PATH
+ environment, click
+ here
+ .
+
+ Through this form you can submit your workshop for approval, once approved your workshop will appear on the website and be installable by the workshop manager. To learn how to build a + workshop, head over to the + docs + or jump on to + slack + for any help! +
+ ++ We are just making sure the quality is high and there is not lots of duplicated content across the workshops. We want PHP School to lead the way in quality education material. +
+The PHP School project is made possible by the following contributors. Thank you for your hard work and dedication to the project!
++ {{ contributor.contributions }} + Commit{{ contributor.contributions > 1 ? "s" : "" }} +
+
+
Example usage:
-<svg><use xlink:href="icons.svg#svg-logo"></use></svg>
-svg-apple
- svg-arrow
- svg-copy
- svg-cross
- svg-cursor
- svg-edit
- svg-github-dk
- svg-github
- svg-header-bg
- svg-home
- svg-linux
- svg-logo
- svg-mail
- svg-menu
- svg-node-school-logo
- svg-node-school
- svg-pencil
- svg-search
- svg-slack
- svg-social-media
- svg-tick
- svg-twitter
- svg-windows
- svg-woman
-