133 lines
7.3 KiB
C#
133 lines
7.3 KiB
C#
using QuestionsApi.Models;
|
|
using System.Globalization;
|
|
|
|
namespace QuestionsApi.Services;
|
|
|
|
public static class QuestionValidator
|
|
{
|
|
public static string? Validate(UpsertQuestionRequest req)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(req.ExternalId)) return "externalId required";
|
|
if (string.IsNullOrWhiteSpace(req.Stem)) return "stem required";
|
|
if (string.IsNullOrWhiteSpace(req.LeadIn)) return "leadIn required";
|
|
if (req.Choices.Count < (req.IsPublished ? 5 : 2))
|
|
return req.IsPublished ? "published questions require at least five choices" : "at least two choices required";
|
|
if (req.Choices.Count > 20) return "questions support at most twenty choices";
|
|
if (req.Choices.Any(choice => string.IsNullOrWhiteSpace(choice.Id) || string.IsNullOrWhiteSpace(choice.Text)))
|
|
return "choice ids and text are required";
|
|
|
|
var choiceIds = req.Choices.Select(choice => choice.Id.Trim()).ToList();
|
|
if (choiceIds.Distinct(StringComparer.Ordinal).Count() != choiceIds.Count)
|
|
return "choice ids must be unique";
|
|
if (string.IsNullOrWhiteSpace(req.CorrectChoiceId)) return "correctChoiceId required";
|
|
if (!choiceIds.Contains(req.CorrectChoiceId.Trim(), StringComparer.Ordinal))
|
|
return "correctChoiceId must match a choice id";
|
|
if (string.IsNullOrWhiteSpace(req.Explanation)) return "explanation required";
|
|
|
|
var choiceIdSet = choiceIds.ToHashSet(StringComparer.Ordinal);
|
|
if (!choiceIdSet.SetEquals(req.ChoiceExplanations.Keys))
|
|
return "choiceExplanations must contain exactly one entry for every choice";
|
|
var incorrectChoiceIds = choiceIdSet
|
|
.Where(id => id != req.CorrectChoiceId.Trim())
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
if (!incorrectChoiceIds.SetEquals(req.WrongAnswerFollowups.Keys))
|
|
return "wrongAnswerFollowups must contain exactly one entry for every incorrect choice";
|
|
if (req.ChoiceSelectionPercentages.Keys.Any(id => !choiceIdSet.Contains(id)))
|
|
return "choiceSelectionPercentages keys must match choice ids";
|
|
if (req.ChoiceSelectionPercentages.Any(entry => entry.Value is < 0 or > 100))
|
|
return "choiceSelectionPercentages values must be between 0 and 100";
|
|
|
|
foreach (var entry in req.WrongAnswerFollowups)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(entry.Value.Prompt)) return "wrongAnswerFollowups prompt required";
|
|
if (string.IsNullOrWhiteSpace(entry.Value.TeachingPoint)) return "wrongAnswerFollowups teachingPoint required";
|
|
if (entry.Value.Choices.Count == 0) return "wrongAnswerFollowups choices required";
|
|
if (entry.Value.Choices.Any(choice => string.IsNullOrWhiteSpace(choice.Id) || string.IsNullOrWhiteSpace(choice.Text)))
|
|
return "wrongAnswerFollowups choice ids and text are required";
|
|
if (entry.Value.Choices.Select(choice => choice.Id).Distinct(StringComparer.Ordinal).Count() != entry.Value.Choices.Count)
|
|
return "wrongAnswerFollowups choice ids must be unique";
|
|
if (!entry.Value.Choices.Any(choice => choice.Id == entry.Value.CorrectChoiceId))
|
|
return "wrongAnswerFollowups correctChoiceId must match a follow-up choice id";
|
|
}
|
|
|
|
if (req.ReviewFollowups is null) return "reviewFollowups must be an array";
|
|
if (req.ReviewFollowups.Count > 10) return "questions support at most ten review follow-ups";
|
|
foreach (var followup in req.ReviewFollowups)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(followup.Prompt)) return "reviewFollowups prompt required";
|
|
if (string.IsNullOrWhiteSpace(followup.TeachingPoint)) return "reviewFollowups teachingPoint required";
|
|
if (followup.Choices.Count < 2) return "reviewFollowups require at least two choices";
|
|
if (followup.Choices.Any(choice => string.IsNullOrWhiteSpace(choice.Id) || string.IsNullOrWhiteSpace(choice.Text)))
|
|
return "reviewFollowups choice ids and text are required";
|
|
if (followup.Choices.Select(choice => choice.Id).Distinct(StringComparer.Ordinal).Count() != followup.Choices.Count)
|
|
return "reviewFollowups choice ids must be unique";
|
|
if (!followup.Choices.Any(choice => choice.Id == followup.CorrectChoiceId))
|
|
return "reviewFollowups correctChoiceId must match a follow-up choice id";
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(req.System)) return "system required";
|
|
if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required";
|
|
if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5";
|
|
var labValidation = ValidateLabs(req.Labs, req.IsPublished);
|
|
if (labValidation is not null) return labValidation;
|
|
var referenceValidation = ValidateReferences(req.References, req.IsPublished);
|
|
if (referenceValidation is not null) return referenceValidation;
|
|
return null;
|
|
}
|
|
|
|
public static string? ValidateLabs(IReadOnlyCollection<QuestionLab>? labs, bool requireComplete)
|
|
{
|
|
if (labs is null)
|
|
return "labs must be an array";
|
|
if (labs.Count > 50)
|
|
return "questions support at most fifty lab results";
|
|
|
|
foreach (var lab in labs)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(lab.Name) || string.IsNullOrWhiteSpace(lab.Value))
|
|
return "lab name and value are required";
|
|
if (requireComplete && string.IsNullOrWhiteSpace(lab.Reference))
|
|
return $"published question lab '{lab.Name.Trim()}' requires a reference range";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static string? ValidateReferences(IReadOnlyCollection<QuestionReference>? references, bool required)
|
|
{
|
|
if (references is null)
|
|
return "verification references must be an array";
|
|
if (required && references.Count == 0)
|
|
return "published questions require at least one verification reference";
|
|
if (references.Count > 20)
|
|
return "questions support at most twenty verification references";
|
|
|
|
var urls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var reference in references)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(reference.Title)
|
|
|| string.IsNullOrWhiteSpace(reference.Publisher)
|
|
|| string.IsNullOrWhiteSpace(reference.Url)
|
|
|| string.IsNullOrWhiteSpace(reference.Supports)
|
|
|| string.IsNullOrWhiteSpace(reference.AccessedOn)
|
|
|| string.IsNullOrWhiteSpace(reference.Reviewer))
|
|
return "verification reference fields are required";
|
|
|
|
var url = reference.Url.Trim();
|
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps)
|
|
return "verification reference urls must be absolute HTTPS urls";
|
|
if (!urls.Add(url))
|
|
return "verification reference urls must be unique within a question";
|
|
if (!DateOnly.TryParseExact(
|
|
reference.AccessedOn.Trim(),
|
|
"yyyy-MM-dd",
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.None,
|
|
out _))
|
|
return "verification reference accessedOn must use YYYY-MM-DD";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|