diff --git a/app/Http/Controllers/AnagramController.php b/app/Http/Controllers/AnagramController.php index b3d8593..d805ee9 100644 --- a/app/Http/Controllers/AnagramController.php +++ b/app/Http/Controllers/AnagramController.php @@ -3,13 +3,15 @@ namespace App\Http\Controllers; use App\Http\Requests\FindAnagramWordsRequest; +use App\Services\AnagramSearch; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; use Illuminate\View\View; -use Lvl0\AnagramFinder\Core\Matcher; class AnagramController extends Controller { + public function __construct(private readonly AnagramSearch $search) {} + public function index(): View { return view('index'); @@ -17,16 +19,30 @@ public function index(): View public function find(FindAnagramWordsRequest $request): RedirectResponse { - $anagram = $request->get('anagram'); - - return redirect()->route('results', $anagram); + return redirect()->route('results', $request->validated('anagram')); } - public function results(Request $request) + public function results(string $anagram): View|RedirectResponse { - $anagram = $request->anagram; - $matches = Matcher::findWords($anagram); + // The URL segment bypasses the form request, so it gets the same rules. + $anagram = strtolower($anagram); - return view('index', ['anagram' => $anagram, 'matches' => $matches]); + $validator = Validator::make( + ['anagram' => $anagram], + (new FindAnagramWordsRequest)->rules(), + (new FindAnagramWordsRequest)->messages(), + ); + + if ($validator->fails()) { + return redirect() + ->route('home') + ->withErrors($validator) + ->withInput(['anagram' => $anagram]); + } + + return view('index', [ + 'anagram' => $anagram, + 'matches' => $this->search->find($anagram), + ]); } } diff --git a/app/Http/Requests/FindAnagramWordsRequest.php b/app/Http/Requests/FindAnagramWordsRequest.php index f2205d7..95c2b68 100644 --- a/app/Http/Requests/FindAnagramWordsRequest.php +++ b/app/Http/Requests/FindAnagramWordsRequest.php @@ -7,23 +7,35 @@ class FindAnagramWordsRequest extends FormRequest { - /** - * Determine if the user is authorized to make this request. - */ public function authorize(): bool { return true; } /** - * Get the validation rules that apply to the request. - * * @return array|string> */ public function rules(): array { return [ - // + 'anagram' => ['required', 'string', 'alpha:ascii', 'min:2', 'max:24'], ]; } + + public function messages(): array + { + return [ + 'anagram.alpha' => 'Letters only — no digits, spaces or punctuation.', + 'anagram.max' => 'That is longer than any word in the dictionary.', + ]; + } + + // The dictionary is lowercase and Matcher::check compares raw characters, + // so an uppercase anagram would silently return nothing. + protected function prepareForValidation(): void + { + $this->merge([ + 'anagram' => strtolower((string) $this->input('anagram', '')), + ]); + } } diff --git a/app/Services/AnagramSearch.php b/app/Services/AnagramSearch.php new file mode 100644 index 0000000..6e0063e --- /dev/null +++ b/app/Services/AnagramSearch.php @@ -0,0 +1,84 @@ +key($anagram, $minLength); + + // Cache a plain array: a serialised Collection comes back as + // __PHP_Incomplete_Class through the database store. + $words = Cache::rememberForever( + $key, + fn () => Matcher::findWords($this->normalise($anagram), $minLength) + ->values() + ->all(), + ); + + return new Collection($words); + } + + /** + * Entries live forever; the dictionary hash in the key is the invalidation + * mechanism. Replacing words.txt makes every old key unreachable rather + * than stale, so nothing has to be flushed by hand. + */ + private function key(string $anagram, int $minLength): string + { + return sprintf( + 'anagram:%s:%d:%s', + $this->dictionaryHash(), + $minLength, + $this->normalise($anagram), + ); + } + + /** + * Sorted letters, so "eamstoxil" and "latoximse" share one entry — the same + * bag of letters is the same query. + */ + private function normalise(string $anagram): string + { + $letters = str_split(strtolower($anagram)); + sort($letters); + + return implode('', $letters); + } + + /** + * Content-addressed, not mtime: the dictionary ships inside the image, so + * its mtime changes on every rebuild even when the words do not. + * + * Hashing 4.6MB per request would undo the saving, so the hash is itself + * cached under a key made of the cheap stat values that change whenever the + * file is replaced. + */ + private function dictionaryHash(): string + { + $path = self::DICTIONARY; + + if (! is_file($path)) { + return 'nodict'; + } + + $stat = filemtime($path) . ':' . filesize($path); + + return Cache::rememberForever( + "anagram:dict:{$stat}", + fn () => hash_file('xxh128', $path), + ); + } +}