|
| 1 | +<!-- |
| 2 | +SPDX-FileCopyrightText: (c) Respect Project Contributors |
| 3 | +SPDX-License-Identifier: ISC |
| 4 | +SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com> |
| 5 | +--> |
1 | 6 | # Respect\Fluent |
2 | 7 |
|
3 | 8 | Build fluent interfaces from class namespaces. PHP 8.5+, zero dependencies. |
@@ -104,7 +109,7 @@ namespace App\Middleware; |
104 | 109 |
|
105 | 110 | use Respect\Fluent\Attributes\Composable; |
106 | 111 |
|
107 | | -#[Composable('optional')] |
| 112 | +#[Composable(self::class)] |
108 | 113 | final readonly class Optional implements Middleware |
109 | 114 | { |
110 | 115 | public function __construct(private Middleware $inner) {} |
@@ -161,232 +166,50 @@ A **FluentNode** carries the resolution state between resolvers and factories: |
161 | 166 | a name, constructor arguments, and an optional wrapper. |
162 | 167 |
|
163 | 168 | ``` |
164 | | - +-----------+ |
165 | | - 'notEmail' --------> | Resolver | ------> FluentNode('Email', wrapper: FluentNode('Not')) |
166 | | - +-----------+ |
| 169 | + +----------+ |
| 170 | + 'notEmail' --------> | Resolver | ------> FluentNode('Email', wrapper: FluentNode('Not')) |
| 171 | + +----------+ |
167 | 172 | | |
168 | 173 | v |
169 | | - +-----------+ |
170 | | - FluentNode -----------> | Factory | ------> Not(Email()) |
171 | | - +-----------+ |
| 174 | + +----------+ |
| 175 | + FluentNode ---------> | Factory | ------> Not(Email()) |
| 176 | + +----------+ |
172 | 177 | ``` |
173 | 178 |
|
174 | 179 | **NamespaceLookup vs ComposingLookup:** use `NamespaceLookup` for simple |
175 | 180 | name-to-class mapping. Wrap it with `ComposingLookup` when you need prefix |
176 | 181 | composition like `notEmail()` → `Not(Email())`. `ComposingLookup` supports |
177 | 182 | recursive unwrapping, so `notNullOrEmail()` → `Not(NullOr(Email()))` works too. |
178 | 183 |
|
179 | | -## API Reference |
| 184 | +## Assurance attributes |
180 | 185 |
|
181 | | -### FluentNamespace (attribute) |
| 186 | +Node classes can declare what they assure about their input via `#[Assurance]`. |
| 187 | +Assertion methods are marked with `#[AssuranceAssertion]`, and `#[AssuranceParameter]` |
| 188 | +identifies specific parameters. Constructor parameters for composition use |
| 189 | +`#[ComposableParameter]`. |
182 | 190 |
|
183 | | -Declares the factory configuration for a builder class. Both the runtime |
184 | | -(`factoryFromAttribute()`) and static analysis (FluentAnalysis) read from this |
185 | | -single source of truth: |
| 191 | +This metadata is available at runtime through reflection and is also consumed |
| 192 | +by tools like [FluentAnalysis](https://github.com/Respect/FluentAnalysis) |
| 193 | +for static type narrowing. |
186 | 194 |
|
187 | 195 | ```php |
188 | | -use Respect\Fluent\Attributes\FluentNamespace; |
189 | | - |
190 | | -// Simple lookup |
191 | | -#[FluentNamespace(new NamespaceLookup(new Ucfirst(), null, 'App\\Handlers'))] |
192 | | - |
193 | | -// With type validation |
194 | | -#[FluentNamespace(new NamespaceLookup(new Ucfirst(), Handler::class, 'App\\Handlers'))] |
195 | | - |
196 | | -// With prefix composition |
197 | | -#[FluentNamespace(new ComposingLookup( |
198 | | - new NamespaceLookup(new Ucfirst(), Validator::class, 'App\\Validators'), |
199 | | -))] |
200 | | -``` |
201 | | - |
202 | | -### Builders |
203 | | - |
204 | | -Abstract base `FluentBuilder` provides `__call`, `__callStatic`, `getNodes()`, |
205 | | -`withNamespace()`, `factoryFromAttribute()`, and the abstract `attach()` method. |
206 | | -Two concrete builders: |
207 | | - |
208 | | -**`Append`** — each `attach()` appends nodes to the end: |
209 | | - |
210 | | -```php |
211 | | -$builder = new Append($factory); |
212 | | -$chain = $builder->cors()->auth('bearer'); |
213 | | -$chain->getNodes(); // [Cors(), Auth('bearer')] |
214 | | -$chain->attach($manualNode); // add pre-built objects |
215 | | -$chain->withNamespace('Extra\\Ns'); // prepend a search namespace |
216 | | -``` |
217 | | - |
218 | | -**`Prepend`** — each `attach()` prepends nodes to the front: |
219 | | - |
220 | | -```php |
221 | | -$builder = new Prepend($factory); |
222 | | -$chain = $builder->cors()->auth('bearer'); |
223 | | -$chain->getNodes(); // [Auth('bearer'), Cors()] |
224 | | -``` |
225 | | - |
226 | | -Both are `readonly` and not `final`, extend them and add your domain methods. |
227 | | -`__callStatic` calls `new static()` by default; override it if your subclass |
228 | | -needs a different way to obtain a default instance. |
229 | | - |
230 | | -### FluentFactory |
231 | | - |
232 | | -Interface implemented by both factories: |
| 196 | +#[Assurance(type: 'int')] |
| 197 | +final readonly class IntType implements Validator { /* ... */ } |
233 | 198 |
|
234 | | -```php |
235 | | -interface FluentFactory |
| 199 | +final readonly class ValidatorBuilder extends Append |
236 | 200 | { |
237 | | - public function create(string $name, array $arguments = []): object; |
238 | | - public function withNamespace(string $namespace): static; |
239 | | -} |
240 | | -``` |
241 | | - |
242 | | -#### NamespaceLookup |
243 | | - |
244 | | -The primary factory. Searches namespaces in order for a matching class. |
245 | | - |
246 | | -```php |
247 | | -$lookup = new NamespaceLookup( |
248 | | - new Ucfirst(), // resolver: 'email' → 'Email' |
249 | | - MyInterface::class, // optional type validation |
250 | | - 'App\\Handlers', // primary namespace |
251 | | - 'App\\Handlers\\Fallback', // fallback namespace |
252 | | -); |
253 | | - |
254 | | -$lookup->create('email', ['strict' => true]); // new App\Handlers\Email(strict: true) |
255 | | -$lookup->resolve('email'); // ReflectionClass (without instantiating) |
256 | | -``` |
257 | | - |
258 | | -The `$resolver` and `$namespaces` properties are `public private(set)`, you |
259 | | -can read them (useful for tooling like FluentAnalysis) but not reassign them. |
260 | | - |
261 | | -Immutable builders: `withNamespace()` prepends a namespace, `withNodeType()` |
262 | | -adds type validation. Both return new instances. |
263 | | - |
264 | | -#### ComposingLookup |
265 | | - |
266 | | -Wraps a `NamespaceLookup` to handle prefix composition. When the resolver |
267 | | -produces a wrapper FluentNode, ComposingLookup creates the inner instance |
268 | | -first, then wraps it. Supports recursive unwrapping for nested wrappers. |
269 | | - |
270 | | -```php |
271 | | -$nested = new ComposingLookup($lookup); // defaults to ComposableAttributes |
272 | | -$nested->create('notEmail'); // Not(Email()) |
273 | | -``` |
274 | | - |
275 | | -You can pass a custom resolver as the second argument if you don't want |
276 | | -automatic `#[Composable]` attribute discovery: |
277 | | - |
278 | | -```php |
279 | | -$nested = new ComposingLookup($lookup, new ComposableMap( |
280 | | - composable: ['not' => true], |
281 | | -)); |
282 | | -``` |
283 | | - |
284 | | -### FluentResolver |
| 201 | + #[AssuranceAssertion] |
| 202 | + public function assert(#[AssuranceParameter] mixed $input): void { /* ... */ } |
285 | 203 |
|
286 | | -Interface for name transformers. Each resolver can `resolve` a method name |
287 | | -into a class name, and `unresolve` it back: |
288 | | - |
289 | | -```php |
290 | | -interface FluentResolver |
291 | | -{ |
292 | | - public function resolve(FluentNode $nodeSpec): FluentNode; |
293 | | - public function unresolve(FluentNode $nodeSpec): FluentNode; |
294 | | -} |
295 | | -``` |
296 | | - |
297 | | -The `unresolve` method is the inverse of `resolve`: it converts a class name |
298 | | -back to the method name that would produce it. This is used by FluentAnalysis |
299 | | -to derive method maps from discovered classes. |
300 | | - |
301 | | -#### Ucfirst |
302 | | - |
303 | | -Capitalizes the first letter: `'email'` → `'Email'`. |
304 | | -Unresolve does the opposite: `'Email'` → `'email'`. |
305 | | - |
306 | | -#### Suffix |
307 | | - |
308 | | -Strips a prefix and appends a suffix: `Suffix('of', 'Handler')` turns |
309 | | -`'ofArray'` → `'ArrayHandler'`. |
310 | | -Unresolve reverses it: `'ArrayHandler'` → `'ofArray'`. |
311 | | - |
312 | | -#### Composable (attribute) |
313 | | - |
314 | | -A PHP attribute that marks a class as a prefix wrapper for composition. |
315 | | -Constraints (`without`, `with`, `optIn`) are enforced at resolve time: |
316 | | - |
317 | | -```php |
318 | | -#[Composable('not', without: ['not'])] // prevents notNot() |
319 | | -final readonly class Not implements Validator |
320 | | -{ |
321 | | - public function __construct(private Validator $validator) {} |
| 204 | + #[AssuranceAssertion] |
| 205 | + public function isValid(#[AssuranceParameter] mixed $input): bool { /* ... */ } |
322 | 206 | } |
323 | 207 | ``` |
324 | 208 |
|
325 | | -Attribute properties: |
326 | | - |
327 | | -| Property | Type | Purpose | |
328 | | -|-------------------|----------|-------------------------------------------------| |
329 | | -| `prefix` | `string` | Registers this class as a composition prefix | |
330 | | -| `prefixParameter` | `bool` | First argument goes to the wrapper | |
331 | | -| `optIn` | `bool` | Only compose with prefixes listed in `with` | |
332 | | -| `without` | `array` | Prefixes this class should not be composed with | |
333 | | -| `with` | `array` | Prefixes this class should be composed with | |
334 | | - |
335 | | -#### ComposableAttributes |
336 | | - |
337 | | -Discovers `#[Composable]` attributes at runtime and decomposes prefixed names: |
338 | | -`'notEmail'` → `FluentNode('Email', wrapper: FluentNode('Not'))`. |
339 | | - |
340 | | -```php |
341 | | -$resolver = new ComposableAttributes($lookup); |
342 | | -``` |
343 | | - |
344 | | -Caches prefix discoveries, suffix constraints, and negative lookups for |
345 | | -performance. Unresolve flattens wrapper structures back to flat names. |
346 | | - |
347 | | -#### ComposableMap |
348 | | - |
349 | | -Pre-built resolver using a compiled prefix map instead of runtime discovery. |
350 | | -Useful for code-generated setups where all prefixes are known ahead of time: |
351 | | - |
352 | | -```php |
353 | | -$resolver = new ComposableMap( |
354 | | - composable: ['not' => true, 'nullOr' => true], |
355 | | - composableWithArgument: ['key' => true], |
356 | | - forbidden: ['Not' => ['not' => true]], // suffix => [prefix => true] |
357 | | -); |
358 | | -``` |
359 | | - |
360 | | -### FluentNode |
361 | | - |
362 | | -Readonly data class carrying resolution state: |
363 | | - |
364 | | -```php |
365 | | -new FluentNode( |
366 | | - name: 'Email', |
367 | | - arguments: ['strict' => true], |
368 | | - wrapper: new FluentNode('Not'), // optional |
369 | | -); |
370 | | -``` |
371 | | - |
372 | | -### Exceptions |
373 | | - |
374 | | -All exceptions implement `FluentException` (a `Throwable` marker interface), |
375 | | -so you can catch all Fluent errors with a single type: |
376 | | - |
377 | | -```php |
378 | | -use Respect\Fluent\Exceptions\FluentException; |
| 209 | +See `Assurance`, `AssuranceParameter`, `ComposableParameter`, and the enum |
| 210 | +types in the [API reference](docs/api.md#assurance) for the full set of options. |
379 | 211 |
|
380 | | -try { |
381 | | - $factory->create('nonExistent'); |
382 | | -} catch (FluentException $e) { |
383 | | - // ... |
384 | | -} |
385 | | -``` |
386 | | - |
387 | | -| Exception | Parent | Thrown when | |
388 | | -|-------------------|----------------------------|------------------------------------------------| |
389 | | -| `CouldNotResolve` | `InvalidArgumentException` | Name not found in any registered namespace | |
390 | | -| `CouldNotCreate` | `InvalidArgumentException` | Instantiation failed or type validation failed | |
| 212 | +## API Reference |
391 | 213 |
|
392 | | -Both extend `InvalidArgumentException` for backwards compatibility. |
| 214 | +See [docs/api.md](docs/api.md) for the complete API reference covering |
| 215 | +attributes, builders, factories, resolvers, and exceptions. |
0 commit comments