Skip to content

Instantly share code, notes, and snippets.

@meglio
Created January 8, 2020 03:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save meglio/a25f5c9ccc4c038a4f25663920ac23ae to your computer and use it in GitHub Desktop.
Save meglio/a25f5c9ccc4c038a4f25663920ac23ae to your computer and use it in GitHub Desktop.
<?php
namespace Toptal\SpeedCoding\Controller\Api;
use Exception;
use Respect\Validation\Validator as v;
use Toptal\SpeedCoding\Api\ApiResult;
use Toptal\SpeedCoding\Auth;
use Toptal\SpeedCoding\Challenge\EmailProcessor;
use Toptal\SpeedCoding\Challenge\TaskResultComparator;
use Toptal\SpeedCoding\Controller\ApiController;
use Toptal\SpeedCoding\Db\Expression;
use Toptal\SpeedCoding\Form\FormValidator;
use Toptal\SpeedCoding\Form\TextWithFormShortcodes;
use Toptal\SpeedCoding\Model\ChallengeTable;
use Toptal\SpeedCoding\Model\Challenge as ChallengeModel;
use Toptal\SpeedCoding\Model\ChallengeTaskTable;
use Toptal\SpeedCoding\Model\Entry as EntryModel;
use Toptal\SpeedCoding\Model\EntryTable;
use Toptal\SpeedCoding\Model\ReferralEmailTable;
use Toptal\SpeedCoding\Model\Task as TaskModel;
use Toptal\SpeedCoding\Model\TaskAttempt;
use Toptal\SpeedCoding\Model\TaskAttemptTable;
use Toptal\SpeedCoding\Model\TaskTable;
use Toptal\SpeedCoding\Referral\ReferralHash;
use Toptal\SpeedCoding\ResponseError\Conflict409Error;
use Toptal\SpeedCoding\ResponseError\Forbidden403Error;
use Toptal\SpeedCoding\ResponseError\NotFound404Error;
use Toptal\SpeedCoding\ResponseError\Redirect;
use Toptal\SpeedCoding\ResponseError\UnprocessableEntity422Error;
use Toptal\SpeedCoding\Validation\Rules\PasswordsMatch;
use Toptal\SpeedCoding\Validation\Validator;
class Entry extends ApiController
{
/**
* Creates a new referral hash for the given challenge and email.
*
* Used in the Welcome screen.
*
* Input:
* - challengeSlug
* - email
*
* Output:
* - refHash
*
* @return ApiResult
* @throws Exception
*/
final public function postCreateReferralHashByEmail()
{
$di = $this->getDiContainer();
return $this->getDb()->wrapInTransaction(function()use($di){
// Challenge
$challenge = $this->getValidateChallengeBySlug();
if (!$challenge->canAcceptANewEntry()) {
throw new Forbidden403Error("This challenge does not accept new entries.");
}
// Email
$email = $this->getVar('email');
try {
$emailValidator = v::notOptional()->email()->setName('Email');
$emailValidator->check($email);
} catch (Exception $e) {
throw new UnprocessableEntity422Error("Please provide a valid email address.");
}
// Create Referral Email record
$refEmailTbl = new ReferralEmailTable($di);
$refEmail = $refEmailTbl->insertNew($email, $challenge->getId());
// Generate Referral Hash
$refHash = (new ReferralHash($di))->generateForReferralEmail(
$challenge->getId(),
$refEmail->getId()
);
$result = new ApiResult;
$result->setData([
'refHash' => $refHash,
]);
return $result;
});
}
/**
* Creates a new entry in a challenge.
*
* Anyone can enter a challenge - no authentication is required.
*
* Reads referral hash from cookies and applies if found a valid one.
*
* Input:
* - challengeSlug
* - email
* - leaderboardName
* - isConfirmedToBeContacted
*
* Output:
* - entry
* - nextTask
* - attemptId
*
* @throws Exception
*/
final public function post()
{
$db = $this->getDb();
$di = $this->getDiContainer();
return $this->getDb()->wrapInTransaction(function()use($db, $di) {
$challenge = $this->getValidateChallengeBySlug();
if (!$challenge->canAcceptANewEntry()) {
throw new Forbidden403Error("This challenge does not accept new entries.");
}
// There must be at least one task available in the challenge
$challengeTaskTable = new ChallengeTaskTable($di);
$countTasks = $challengeTaskTable->countTasksInChallenge($challenge->getId());
if (!$countTasks) {
throw new UnprocessableEntity422Error("Cannot enter a challenge with zero tasks available.");
}
// Email
$email = $this->getVar('email');
try {
$emailValidator = v::notOptional()->email()->setName('Email');
$emailValidator->check($email);
} catch (Exception $e) {
throw new UnprocessableEntity422Error("Please provide a valid email address.");
}
// Leaderboard Name
$leaderboardName = $this->getVar('leaderboardName');
Validator::leaderBoardName(false)->check($leaderboardName);
// Retrieve the checkbox value
$isConfirmedToBeContacted = $this->getValidateIsConfirmedToBeContacted(
$challenge->getConfirmationRequiredText()
);
// Check if referred by anyone
$referredByEmail = null;
$ref = ReferralHash::getFromCookies($di);
if ($ref && $ref->isValid()) {
$referredByEntry = $ref->getEntry();
if ($referredByEntry) {
$referredByEmail = $referredByEntry->getEmail();
}
if (!$referredByEmail) {
$refEmailRecord = $ref->getReferralEmail();
if ($refEmailRecord) {
$referredByEmail = $refEmailRecord->getEmail();
}
}
}
// Don't store the referrer email
// if it's the same as the entry email
if (strtolower($referredByEmail) === strtolower($email)) {
$referredByEmail = null;
}
// Generate a random Entry Key
$entryKey = Auth::generateApiKey();
$timeLimitSec = $challenge->getTimeLimitSec();
$modelRow = $db->insertReturning('entry', [
'challenge_id' => $challenge->getId(),
'email' => $email,
'leaderboard_name' => $leaderboardName,
'entry_key' => $entryKey,
'total_points' => 0,
'date_created' => Expression::currentTimestamp(),
'date_stop' => new Expression(
"CURRENT_TIMESTAMP + make_interval(secs => $timeLimitSec)"
),
'confirm_to_be_contacted_text' => $isConfirmedToBeContacted?
$challenge->getConfirmToBeContactedText() : '',
'is_confirmed_to_be_contacted' => $isConfirmedToBeContacted,
'referred_by_email' => $referredByEmail
], '*');
$entry = EntryModel::createFromDbRow($modelRow);
$entry->setDIContainer($di);
// Retrieve the next task, so that we can return it in the same API request
$taskMeta = $entry->getNextTaskMeta();
$nextTaskId = $taskMeta->getNextTaskId();
if (!$nextTaskId) {
throw new UnprocessableEntity422Error("Cannot enter a challenge with zero tasks available.");
}
$taskTbl = new TaskTable($di);
$task = $taskTbl->fetchById($nextTaskId);
if (!$task) {
throw new UnprocessableEntity422Error(
sprintf(
'Could not fetch Next Task by its ID = %s (not found)',
$nextTaskId
)
);
}
// Create a new task attempt
$taskAttemptTbl = new TaskAttemptTable($this->getDiContainer());
$attemptId = $taskAttemptTbl->startNewAttempt($entry->getId(), $task->getId());
$result = new ApiResult;
$result->setData([
'entry' => $entry->unsetSensitiveAndAdminOnlyFields(),
'nextTask' => $task->unsetSensitiveAndAdminOnlyFields(),
'attemptId' => $attemptId
]);
return $result;
});
}
/**
* Skips a task given the corresponding attempt ID.
*
* Input:
* - attempt_id - the ID of the task to skip (provided in request body)
*
* Output:
* - nextTask - the next task to solve
* - attemptId - the ID of the attempt for the next task
*
* @param int $id The Entry ID (provided in URL)
* @throws Exception
*
* @return ApiResult
*/
final public function postSkipTaskById($id)
{
$db = $this->getDb();
$di = $this->getDiContainer();
return $this->getDb()->wrapInTransaction(function()use($id, $db, $di) {
// Fetch the Entry to be updated
/**
* Retrieve an Entry by its ID, make sure it has not finished yet.
*
* @var EntryModel $entry
* @var EntryTable $entryTable
*/
list($entry, $entryTable) = $this->getValidateEntryById($id, true);
$entryId = $entry->getId();
// Fetch the attempt to be skipped
/**
* @var TaskAttempt $attempt
* @var TaskAttemptTable $attemptTbl
*/
list($attempt, $attemptTbl) = $this->getValidateAttempt(true);
$taskId = $attempt->getTaskId();
// Fetch the task to be skipped and make sure it exists
$taskTbl = new TaskTable($this->getDiContainer());
$task = $taskTbl->fetchById($taskId);
if (!$task) {
throw new UnprocessableEntity422Error("Task ID does not exist.");
}
// Make sure the task has not been skipped already.
// Note. Task can be skipped multiple times, but only as a result
// of resetting the list of skipped task IDs.
if ($entry->isTaskSkipped($taskId)) {
throw new UnprocessableEntity422Error('Cannot skip a task that has already been skipped in this challenge entry.');
}
// Disable skipping the last task
$entryState = $entry->getState();
if ($entryState->getCountUnsolvedTasks() <= 1) {
throw new UnprocessableEntity422Error("Cannot skip last task.");
}
// Record a skip of the task attempt
$attemptTbl->markSkipped($attempt->getId());
// Update the entry with the new skipped task ID
$entry->skipTask($taskId);
$nextTaskMeta = $entry->getNextTaskMeta();
$nextTaskId = $nextTaskMeta->getNextTaskId();
$nextTask = $taskTbl->fetchById($nextTaskId);
if (!$nextTask) {
throw new UnprocessableEntity422Error("Could not retrieve the next Task.");
}
// Create a new task attempt
$newAttemptId = $attemptTbl->startNewAttempt($entryId, $nextTaskId);
$result = new ApiResult;
$result->setData([
'nextTask' => $nextTask->unsetSensitiveAndAdminOnlyFields(),
'attemptId' => $newAttemptId
]);
return $result;
});
}
/**
* Attempts a task.
*
* Input:
* - attempt_id - the ID of the attempt previously created
* - code - the user code used to produce tests
* - tests_json - a JSON-encoded array of user code test results:
* - keys are test nicknames
* - values are results returned by the user code
*
* Output:
* - isSuccess - whether all tests passed
* - testsFlags - an array of boolean OK/Failed test flags by test nicknames
* - isChallengeEntryFinished - whether the challenge entry is finished
* because there is no more time / no more tasks to solve
* - attemptId - the ID of the next attempt
* - nextTask - the next task, if any
* - totalPoints - the actual total points value, possibly incremented after
* the successful attempt
*
* @param int $id The Entry ID (provided in URL)
* @return ApiResult
* @throws Exception
*/
final public function postAttemptTaskById($id)
{
$db = $this->getDb();
$di = $this->getDiContainer();
return $this->getDb()->wrapInTransaction(function()use($id, $db, $di) {
// Fetch the Entry to be updated
/**
* @var EntryModel $entry
* @var EntryTable $entryTbl
*/
list($entry, $entryTbl) = $this->getValidateEntryById($id, true);
$entryId = $entry->getId();
// Fetch the attempt to be processed
/**
* @var TaskAttempt $attempt
* @var TaskAttemptTable $attemptTbl
*/
list($attempt, $attemptTbl) = $this->getValidateAttempt(true);
$taskId = $attempt->getTaskId();
// Fetch the task to be attempted and make sure it exists
$taskTbl = new TaskTable($this->getDiContainer());
$task = $taskTbl->fetchById($taskId);
if (!$task) {
throw new UnprocessableEntity422Error("Task ID does not exist.");
}
// Make sure the task has not been skipped - a skipped task cannot be attempted
// until the list of skipped IDs gets full and gets reset.
if ($entry->isTaskSkipped($taskId)) {
throw new UnprocessableEntity422Error('Cannot process a task attempt that has already been skipped in this challenge entry.');
}
// Fetch EntryState for the first time to detect some cases early.
// We will re-fetch it again later after updating tables in database.
$entryState = $entry->getState();
if ($entryState->isSolvedSuccessfully($taskId)) {
throw new UnprocessableEntity422Error("This task has been successfully solved already in this challenge entry and thus cannot be processed again.");
}
if (!$entryState->isValidTaskId($taskId)) {
throw new UnprocessableEntity422Error("This Task is not part of the challenge and thus the attempt cannot be processed.");
}
// Get user code test results:
// - keys are test nicknames
// - values are results returned by the user code
$tests = $this->getValidateTests();
// The user code for this attempt
$code = $this->getValidateCode();
$comp = new TaskResultComparator($task->getTestsJsonDecoded(), $tests);
$areAllTestsOK = $comp->areAllTestsOK();
// Finalize the task attempt
$attemptTbl->markFinished(
$attempt->getId(),
$areAllTestsOK,
$code,
$tests
);
$apiResData = [
'isSuccess' => $areAllTestsOK,
'testsFlags' => $comp->getTests()
];
// Attempt SUCCEEDED!
if ($areAllTestsOK) {
// Increment total points in the entry
$plusPoints = $task->getPoints();
$entry->incTotalPoints($plusPoints);
// Randomly pick next task ID for tests
$nextTaskMeta = $entry->getNextTaskMeta();
$nextTaskId = $nextTaskMeta->getNextTaskId();
} else {
// Attempt FAILED
// Attempt the same task
$nextTaskId = $task->getId();
}
$apiResData['totalPoints'] = $entry->getTotalPoints();
if ($entry->isFinished()) {
// No more time
$isChallengeEntryFinished = true;
} else if (!$nextTaskId) {
// No more tasks - all tasks completed!
$isChallengeEntryFinished = true;
$challengeTbl = new ChallengeTable($di);
$challenge = $challengeTbl->fetchById($entry->getChallengeId());
if (!$challenge) {
throw new UnprocessableEntity422Error("Could not retrieve the Challenge for this Task Attempt.");
}
$entry->incTotalPointsForRemainingTime($challenge->getPointsPerSecondLeft());
$apiResData['totalPoints'] = $entry->getTotalPoints();
} else {
// There are more tasks to tackle!
$isChallengeEntryFinished = false;
$nextTask = $taskTbl->fetchById($nextTaskId);
if (!$nextTask) {
throw new UnprocessableEntity422Error("Could not retrieve the next Task.");
}
$apiResData['nextTask'] = $nextTask->unsetSensitiveAndAdminOnlyFields();
// Create a new task attempt
$newAttemptId = $attemptTbl->startNewAttempt($entryId, $nextTaskId);
$apiResData['attemptId'] = $newAttemptId;
}
$apiResData['isChallengeEntryFinished'] = $isChallengeEntryFinished;
// When the Entry is finished, process all Challenge Event Emails
if ($isChallengeEntryFinished) {
$emailProcessor = new EmailProcessor(
$this->getDiContainer(),
$entry->getChallengeId(),
// Passing Entry ID rather then the Entry itself will make
// the email processor fetch the latest Entry data from database
$entryId
);
$emailProcessor->process();
}
$result = new ApiResult;
$result->setData($apiResData);
return $result;
});
}
/**
* Submits a form for this entry.
*
* Forms can be submitted at any time, even after the entries got expired.
*
* Input:
* - entry_key - the Entry Key
* - form_response_json - the Form Response array as a JSON-encoded string
*
* Output:
* - (nothing)
*
* @return ApiResult
* @throws Exception
*/
final public function postSubmitForm()
{
$db = $this->getDb();
$di = $this->getDiContainer();
return $this->getDb()->wrapInTransaction(function()use($db, $di) {
/**
* @var EntryModel $entry
* @var EntryTable $entryTable
*/
list($entry, $entryTable) = $this->getValidateEntryByKey();
if ($entry->getIsRemoved()) {
return new NotFound404Error('Entry not found');
}
// The form has been submitted and recorded already
if ($entry->hasFormResponse()) {
throw new Conflict409Error('This form has been submitted and recorded already');
}
// Validate Form Values form user input
$formValues = $this->getValidateFormResponseJson();
// Retrieve the Challenge corresponding to this Entry
$challengeTable = $this->newChallengeTable();
/**
* @var ChallengeModel $challenge
*/
$challenge = $challengeTable->fetchById($entry->getChallengeId());
$html = $challenge->getTyBodyHtml();
$htmlParsed = new TextWithFormShortcodes($di, $html);
$form = $htmlParsed->getForm();
if (!$form) {
throw new UnprocessableEntity422Error("This challenge does not have any forms.");
}
$formSpec = $form->getFormSpecJsonDecoded();
$validator = new FormValidator($formSpec, $formValues);
if (!$validator->isValid()) {
throw new UnprocessableEntity422Error(
$validator->getValidationError()
);
}
$validatedValues = $validator->getValidatedValues();
$entry->recordFormSubmission(
$formSpec,
$validatedValues
);
return new ApiResult();
});
}
/**
* @return ChallengeModel
* @throws Exception
*/
private function getValidateChallengeBySlug()
{
// Extract and validate challenge slug
$slug = $this->getVar('challengeSlug');
// Slug cannot be empty
if (!$slug) {
throw new NotFound404Error;
}
// Check for some sane slug length limit
if (mb_strlen($slug) > 1000) {
throw new NotFound404Error;
}
$challengeTable = new ChallengeTable($this->getDiContainer());
$challenge = $challengeTable->fetchBySlug($slug);
// The challenge must exist
if (!$challenge) {
throw new NotFound404Error('Challenge not found.');
}
return $challenge;
}
/**
* Retrieves an Entry by its ID, optionally validating it has not finished yet.
* @param $id
* @param bool $ensureNotFinished
* @return array
* @throws Exception
*/
private function getValidateEntryById($id, $ensureNotFinished = true)
{
// Fetch the Entry to be updated
$entryTable = new EntryTable($this->getDiContainer());
$entry = $entryTable->fetchById($id);
if (!$entry) {
throw new NotFound404Error;
}
if ($ensureNotFinished && $entry->isFinished()) {
throw new UnprocessableEntity422Error("This Entry has finished and cannot be updated.");
}
return [$entry, $entryTable];
}
/**
* Gets a task by its ID and validates that the task exists.
*
* @throws Exception
*/
private function getValidateTask()
{
// Validate input data
$fields = $this->getVarsValidate(false, [
'task_id' => [
'is_required' => true,
'validator' => function($value) {
Validator::id('Task ID')->check($value);
}
]
]);
// Make sure the task exists
$taskId = $fields['task_id'];
$taskTbl = new TaskTable($this->getDiContainer());
$task = $taskTbl->fetchById($taskId);
if (!$task) {
throw new UnprocessableEntity422Error("Task ID does not exist.");
}
return [$task, $taskTbl];
}
/**
* Gets an entry by its Key and validates that the entry exists.
*
* @return array
* @throws Exception
*/
private function getValidateEntryByKey()
{
$entryKey = $this->getVar('entry_key');
if (!$entryKey || strlen($entryKey) > 1000) {
throw new NotFound404Error;
}
$entryTable = $this->newEntryTable();
$entry = $entryTable->fetchByKey($entryKey);
if (!$entry) {
throw new NotFound404Error;
}
return [$entry, $entryTable];
}
/**
* Gets a Task Attempt by its ID and validates that it exists.
*
* @param bool $validateIsNotFinished
* @return array
* @throws Exception
*/
private function getValidateAttempt($validateIsNotFinished = true)
{
// Validate input data
$fields = $this->getVarsValidate(false, [
'attempt_id' => [
'is_required' => true,
'validator' => function($value) {
Validator::id('Attempt ID')->check($value);
}
]
]);
// Make sure the attempt exists
$attemptId = $fields['attempt_id'];
$attemptTable = new TaskAttemptTable($this->getDiContainer());
$attempt = $attemptTable->fetchById($attemptId);
if (!$attempt) {
throw new UnprocessableEntity422Error("Attempt ID does not exist.");
}
if ($validateIsNotFinished && $attempt->isFinished()) {
throw new UnprocessableEntity422Error("This attempt was processed already and cannot be processed again.");
}
return [$attempt, $attemptTable];
}
/**
* Gets an array of user code test results:
* - keys are test nicknames
* - values are results returned by user code (usually a scalar value or an array)
*
* @return array
*/
private function getValidateTests()
{
$json = $this->getVar('tests_json');
if (!is_string($json) || $json === '') {
throw new UnprocessableEntity422Error("Invalid tests JSON.");
}
// Some sane limits to the tests_json string
if (mb_strlen($json) > 100000) {
throw new UnprocessableEntity422Error("Tests JSON too long.");
}
$tests = json_decode($json, true);
if (!is_array($tests)) {
throw new UnprocessableEntity422Error("Tests JSON is not an array.");
}
foreach ($tests as $testName => $testResult) {
if (!is_string($testName)) {
throw new UnprocessableEntity422Error("Test key not a string.");
}
if (!is_null($testResult) && !is_scalar($testResult) && !is_array($testResult)) {
throw new UnprocessableEntity422Error("Test result is not a scalar value and is not an array.");
}
}
return $tests;
}
private function getValidateCode()
{
$code = $this->getVar('code');
if (empty($code)) {
$code = '';
}
return $code;
}
/**
* Gets and validates the value of the "I confirm to be contacted" checkbox.
*
* If validation error $confirmationRequiredText is a non-empty string,
* then the checkbox is required to be checked; otherwise it is optional.
*
* @param string $confirmationRequiredText
* @return bool
*/
private function getValidateIsConfirmedToBeContacted($confirmationRequiredText)
{
$isConfirmed = !!$this->getVar('isConfirmedToBeContacted');
// If validation error is a non-empty string,
// the checkbox becomes required.
if (!$isConfirmed && $confirmationRequiredText !== '') {
throw new UnprocessableEntity422Error($confirmationRequiredText);
}
return $isConfirmed;
}
/**
* Gets an array representing form values from user input:
* - keys are field nicknames
* - values are the values of the corresponding fields
*
* @return array
*/
private function getValidateFormResponseJson()
{
$json = $this->getVar('form_response_json');
if (!is_string($json) || $json === '') {
throw new UnprocessableEntity422Error("Invalid Form Response JSON.");
}
// Some sane limits to the form_response_json string
if (mb_strlen('form_response_json') > 100000) {
throw new UnprocessableEntity422Error("Form Response JSON too long.");
}
$formValues = json_decode($json, true);
if (!is_array($formValues)) {
throw new UnprocessableEntity422Error("Form Response JSON is not an array.");
}
foreach ($formValues as $fieldKey => $fieldValue) {
if (!is_string($fieldKey)) {
throw new UnprocessableEntity422Error("Field Key not a string.");
}
if (!is_scalar($fieldValue) && !is_array($fieldValue)) {
throw new UnprocessableEntity422Error("Field Value is not a scalar value and is not an array.");
}
}
return $formValues;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment