Skip to content

Commit c96ff26

Browse files
christophechevalierZorin95670
authored andcommitted
feat: validate dynamic entity attribute
- Add POST /api/{entity}/validate/{attributeName} endpoint (accept raw request body) - Run configured attribute validators (no side effects) - Expose validate route in /metadata/routes with disabledRoutes support - Add i18n message for unknown attribute - Refocus controller tests on delegation; add validateAttribute engine coverage - Cleanup redundant override Javadoc
1 parent bfe2ffa commit c96ff26

File tree

10 files changed

+227
-2
lines changed

10 files changed

+227
-2
lines changed

src/main/java/io/github/linagora/linid/im/controller/GenericController.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,4 +190,28 @@ public ResponseEntity<Void> deleteEntity(@PathVariable String entity, @PathVaria
190190

191191
return ResponseEntity.noContent().build();
192192
}
193+
194+
/**
195+
* Validates a single attribute value for a dynamic entity.
196+
*
197+
* <p>
198+
* This endpoint performs validation only (no side-effects). When validation succeeds, HTTP 204 (No Content) is returned.
199+
*
200+
* @param entityRoute entity route (as used in configuration)
201+
* @param attributeName attribute name to validate
202+
* @param value request body value to validate; may be omitted, in which case the value is treated as {@code null} for
203+
* validation purposes
204+
* @param request HTTP request
205+
* @return 204 No Content on success; 404 if entity or attribute not found; 400 if validation fails
206+
*/
207+
@PostMapping("/validate/{attributeName}")
208+
public ResponseEntity<Void> validateAttribute(
209+
@PathVariable("entity") String entityRoute,
210+
@PathVariable("attributeName") String attributeName,
211+
@RequestBody(required = false) Object value,
212+
HttpServletRequest request) {
213+
service.validateAttribute(entityRoute, attributeName, value);
214+
return ResponseEntity.noContent().build();
215+
}
216+
193217
}

src/main/java/io/github/linagora/linid/im/plugin/config/PluginConfigurationServiceImpl.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ public List<RouteDescription> getRouteDescriptions() {
203203

204204
final String defaultRoutePattern = "/api/%s";
205205
final String routeWithIdPattern = "/api/%s/{id}";
206+
final String validateAttributePattern = "/api/%s/validate/{attributeName}";
206207

207208
this.root.getEntities().forEach(entity -> {
208209
routeDescriptions.add(
@@ -247,6 +248,16 @@ public List<RouteDescription> getRouteDescriptions() {
247248
List.of("id")
248249
));
249250
}
251+
252+
if (!entity.getDisabledRoutes().contains("validate")) {
253+
routeDescriptions.add(
254+
new RouteDescription(
255+
"POST",
256+
String.format(validateAttributePattern, entity.getRoute()),
257+
entity.getName(),
258+
List.of("attributeName")
259+
));
260+
}
250261
});
251262

252263
this.routeRegistry.getPlugins().forEach(plugin -> routeDescriptions

src/main/java/io/github/linagora/linid/im/plugin/entity/DynamicEntityServiceImpl.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,4 +322,12 @@ public ProviderConfiguration getProviderConfiguration(DynamicEntity entity) {
322322
"provider", entity.getConfiguration().getProvider())
323323
)));
324324
}
325+
326+
@Override
327+
public void validateAttribute(String entityName, String attributeName, Object value) {
328+
var entity = new DynamicEntity();
329+
updateEntityConfiguration(entity, entityName);
330+
331+
validationEngine.validateAttribute(entity, attributeName, value);
332+
}
325333
}

src/main/java/io/github/linagora/linid/im/plugin/validation/ValidationEngineImpl.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,45 @@ public void validate(DynamicEntity dynamicEntity, String phase) {
9696
}
9797
}
9898

99+
@Override
100+
public void validateAttribute(DynamicEntity dynamicEntity, String attributeName, Object value) {
101+
var attributeConfiguration = dynamicEntity.getConfiguration()
102+
.getAttributes()
103+
.stream()
104+
.filter(attribute -> attributeName.equals(attribute.getName()))
105+
.findFirst()
106+
.orElseThrow(() -> new ApiException(
107+
404,
108+
I18nMessage.of(
109+
"error.attribute.unknown",
110+
Map.of(
111+
"entity", dynamicEntity.getConfiguration().getName(),
112+
"attribute", attributeName))));
113+
114+
List<I18nMessage> errors = new ArrayList<>();
115+
116+
attributeConfiguration.getValidations()
117+
.stream()
118+
.map(this::mergeConfigurationWithGlobal)
119+
.forEach(configuration -> getValidationPlugin(configuration)
120+
.validate(configuration, value)
121+
.ifPresent(error -> {
122+
error.context().put("entity", dynamicEntity.getConfiguration().getName());
123+
error.context().put("attribute", attributeName);
124+
125+
errors.add(error);
126+
}));
127+
128+
if (!errors.isEmpty()) {
129+
throw new ApiException(
130+
400,
131+
I18nMessage.of(
132+
"error.entity.attributes",
133+
Map.of("entity", dynamicEntity.getConfiguration().getName())),
134+
Map.of("errors", errors));
135+
}
136+
}
137+
99138
/**
100139
* Resolves the correct {@link ValidationPlugin} for a given validation configuration.
101140
*

src/main/resources/i18n/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"error.router.unknown.route": "Unknown route: {route}",
33
"error.entity.unknown": "Unknown entity: {entity}",
4+
"error.attribute.unknown": "Unknown attribute '{attribute}' for entity '{entity}'",
45
"error.entity.attributes": "Validation errors occurred for entity: {entity}",
56
"error.provider.unknown": "Unknown provider '{provider}' for entity '{entity}'",
67
"error.plugin.unknown": "Unknown plugin: {type}"

src/main/resources/i18n/fr.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"error.router.unknown.route": "Route inconnue: {route}",
33
"error.entity.unknown": "Entité inconnue: {entity}",
4+
"error.attribute.unknown": "Attribut inconnu '{attribute}' pour l'entité '{entity}'",
45
"error.entity.attributes": "Erreurs de validation pour l'entité: {entity}",
56
"error.provider.unknown": "Provider inconnu '{provider}' pour l'entité '{entity}'",
67
"error.plugin.unknown": "Plugin inconnu: {type}"

src/test/java/io/github/linagora/linid/im/controller/GenericControllerTest.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828

2929
import static org.junit.jupiter.api.Assertions.assertEquals;
3030
import static org.junit.jupiter.api.Assertions.assertNotNull;
31+
import static org.mockito.ArgumentMatchers.any;
32+
import static org.mockito.ArgumentMatchers.anyString;
33+
import static org.mockito.ArgumentMatchers.eq;
34+
import static org.mockito.Mockito.doNothing;
3135

3236
import io.github.linagora.linid.im.corelib.plugin.entity.DynamicEntity;
3337
import io.github.linagora.linid.im.corelib.plugin.entity.DynamicEntityMapper;
@@ -168,4 +172,22 @@ void testDeleteEntity() {
168172
assertNotNull(response);
169173
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
170174
}
175+
176+
@Test
177+
@DisplayName("test validateAttribute: should return 204 when valid")
178+
void testValidateAttributeWhenValid() {
179+
var request = Mockito.mock(HttpServletRequest.class);
180+
doNothing().when(service).validateAttribute(eq("users"), eq("email"), eq("a@b.com"));
181+
182+
ResponseEntity<Void> response = controller.validateAttribute(
183+
"users",
184+
"email",
185+
"a@b.com",
186+
request
187+
);
188+
189+
assertNotNull(response);
190+
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
191+
Mockito.verify(service).validateAttribute(eq("users"), eq("email"), eq("a@b.com"));
192+
}
171193
}

src/test/java/io/github/linagora/linid/im/plugin/config/PluginConfigurationServiceImplTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ void shouldReturnExcludeDisabledRoutes() {
246246
EntityConfiguration entity = new EntityConfiguration();
247247
entity.setName("User");
248248
entity.setRoute("users");
249-
entity.setDisabledRoutes(List.of("create", "update", "patch", "delete", "findById", "findAll"));
249+
entity.setDisabledRoutes(List.of("create", "update", "patch", "delete", "findById", "findAll", "validate"));
250250

251251
RootConfiguration root = new RootConfiguration();
252252
root.setEntities(List.of(entity));

src/test/java/io/github/linagora/linid/im/plugin/entity/DynamicEntityServiceImplTest.java

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,10 @@ void testHandleFindAll() {
311311
service.handleFindAll(request, "test", MultiValueMap.fromMultiValue(Map.of()), null);
312312

313313
Mockito.verify(authPlugin, Mockito.times(1)).validateToken(Mockito.any(), Mockito.any());
314-
Mockito.verify(authPlugin, Mockito.times(1)).isAuthorized(Mockito.any(), Mockito.any(), Mockito.any(MultiValueMap.class),
314+
Mockito.verify(authPlugin, Mockito.times(1)).isAuthorized(
315+
Mockito.any(),
316+
Mockito.any(),
317+
Mockito.<MultiValueMap<String, String>>any(),
315318
Mockito.eq("READ"),
316319
Mockito.any());
317320
Mockito.verify(validationEngine, Mockito.times(1)).validate(Mockito.any(), Mockito.eq("beforeFindAll"));
@@ -380,4 +383,33 @@ void testGetProviderConfiguration() {
380383
assertEquals(Map.of("entity", "name", "provider", "provider"), ex.getError().context());
381384
assertEquals(500, ex.getStatusCode());
382385
}
386+
387+
@Test
388+
@DisplayName("test validateAttribute: should load configuration and delegate to validation engine")
389+
void testValidateAttributeShouldDelegate() {
390+
var entityConfiguration = new EntityConfiguration();
391+
entityConfiguration.setName("users");
392+
393+
Mockito.when(configurationService.getEntityConfiguration("users"))
394+
.thenReturn(Optional.of(entityConfiguration));
395+
Mockito.doNothing().when(validationEngine).validateAttribute(Mockito.any(), Mockito.any(), Mockito.any());
396+
397+
service.validateAttribute("users", "email", "a@b.com");
398+
399+
ArgumentCaptor<DynamicEntity> entityCaptor = ArgumentCaptor.forClass(DynamicEntity.class);
400+
Mockito.verify(validationEngine).validateAttribute(entityCaptor.capture(), Mockito.eq("email"), Mockito.eq("a@b.com"));
401+
assertEquals(entityConfiguration, entityCaptor.getValue().getConfiguration());
402+
}
403+
404+
@Test
405+
@DisplayName("test validateAttribute: should throw when entity is unknown")
406+
void testValidateAttributeShouldThrowWhenEntityUnknown() {
407+
Mockito.when(configurationService.getEntityConfiguration("users")).thenReturn(Optional.empty());
408+
409+
ApiException ex = assertThrows(ApiException.class, () -> service.validateAttribute("users", "email", "a@b.com"));
410+
411+
assertEquals(404, ex.getStatusCode());
412+
assertEquals("error.entity.unknown", ex.getError().key());
413+
assertEquals(Map.of("entity", "users"), ex.getError().context());
414+
}
383415
}

src/test/java/io/github/linagora/linid/im/plugin/validation/ValidationEngineImplTest.java

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,93 @@ public Optional<I18nMessage> validate(ValidationConfiguration configuration, Obj
223223
assertFalse(errors.isEmpty());
224224
}
225225

226+
@Test
227+
@DisplayName("test validateAttribute: should not throw when validation succeeds")
228+
void testValidateAttributeShouldNotThrow() {
229+
var plugin = Mockito.spy(new DummyPlugin());
230+
231+
var config = new ValidationConfiguration();
232+
config.setName("val1");
233+
config.setType("type1");
234+
235+
var attrConfig = new AttributeConfiguration();
236+
attrConfig.setName("email");
237+
attrConfig.setValidations(List.of(config));
238+
239+
var entityConfig = new EntityConfiguration();
240+
entityConfig.setName("users");
241+
entityConfig.setAttributes(List.of(attrConfig));
242+
243+
var entity = Mockito.mock(DynamicEntity.class);
244+
Mockito.when(entity.getConfiguration()).thenReturn(entityConfig);
245+
246+
Mockito.when(configurationService.getValidationConfiguration("val1")).thenReturn(Optional.empty());
247+
Mockito.when(validationRegistry.getPlugins()).thenReturn(List.of(plugin));
248+
249+
assertDoesNotThrow(() -> validationEngine.validateAttribute(entity, "email", "a@b.com"));
250+
}
251+
252+
@Test
253+
@DisplayName("test validateAttribute: should throw 404 when attribute does not exist")
254+
void testValidateAttributeShouldThrowWhenAttributeUnknown() {
255+
var entityConfig = new EntityConfiguration();
256+
entityConfig.setName("users");
257+
entityConfig.setAttributes(List.of());
258+
259+
var entity = Mockito.mock(DynamicEntity.class);
260+
Mockito.when(entity.getConfiguration()).thenReturn(entityConfig);
261+
262+
ApiException ex = assertThrows(ApiException.class, () -> validationEngine.validateAttribute(entity, "email", "a@b.com"));
263+
assertEquals(404, ex.getStatusCode());
264+
assertEquals("error.attribute.unknown", ex.getError().key());
265+
assertEquals("users", ex.getError().context().get("entity"));
266+
assertEquals("email", ex.getError().context().get("attribute"));
267+
}
268+
269+
@Test
270+
@DisplayName("test validateAttribute: should throw 400 when validation fails")
271+
void testValidateAttributeShouldThrowWhenInvalid() {
272+
var plugin = Mockito.spy(new DummyPlugin() {
273+
@Override
274+
public Optional<I18nMessage> validate(ValidationConfiguration configuration, Object value) {
275+
return Optional.of(I18nMessage.of("error.test", Map.of("reason", "invalid")));
276+
}
277+
});
278+
279+
var config = new ValidationConfiguration();
280+
config.setName("val1");
281+
config.setType("type1");
282+
283+
var attrConfig = new AttributeConfiguration();
284+
attrConfig.setName("email");
285+
attrConfig.setValidations(List.of(config));
286+
287+
var entityConfig = new EntityConfiguration();
288+
entityConfig.setName("users");
289+
entityConfig.setAttributes(List.of(attrConfig));
290+
291+
var entity = Mockito.mock(DynamicEntity.class);
292+
Mockito.when(entity.getConfiguration()).thenReturn(entityConfig);
293+
294+
Mockito.when(configurationService.getValidationConfiguration("val1")).thenReturn(Optional.empty());
295+
Mockito.when(validationRegistry.getPlugins()).thenReturn(List.of(plugin));
296+
297+
ApiException ex = assertThrows(ApiException.class, () -> validationEngine.validateAttribute(entity, "email", "not-an-email"));
298+
assertEquals(400, ex.getStatusCode());
299+
assertEquals("error.entity.attributes", ex.getError().key());
300+
assertEquals("users", ex.getError().context().get("entity"));
301+
302+
var errors = (List<?>) ex.getDetails().get("errors");
303+
assertNotNull(errors);
304+
assertEquals(1, errors.size());
305+
306+
var error = (I18nMessage) errors.get(0);
307+
assertEquals("error.test", error.key());
308+
assertEquals("invalid", error.context().get("reason"));
309+
assertEquals("users", error.context().get("entity"));
310+
assertEquals("email", error.context().get("attribute"));
311+
}
312+
226313

227314
public static class DummyPlugin implements ValidationPlugin {
228315
@Override

0 commit comments

Comments
 (0)