Skip to content

Commit

Permalink
feat: Add enctype to Form (#676)
Browse files Browse the repository at this point in the history
* feat: Add enctype to Form

Close: #674

* remove pattern from constructor arguments

* fix checkstyle

* add validation test

* validate message

* Handle null in EnctypePostRequiredValidator

---------

Co-authored-by: Tim Yates <tim.yates@gmail.com>
  • Loading branch information
sdelamo and timyates authored Dec 18, 2023
1 parent 9f44ac2 commit 2e01053
Show file tree
Hide file tree
Showing 11 changed files with 316 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<form th:fragment="form(form)" xmlns:th="http://www.thymeleaf.org" th:action="${form.action()}" th:method="${form.method()}"><fieldset th:replace="~{fieldset/fieldset :: fieldset(${form.fieldset()})}"></fieldset></form>
<th:block th:fragment="form(form)" xmlns:th="http://www.thymeleaf.org"><form th:action="${form.action()}" th:method="${form.method()}" th:enctype="${form.enctype()}"><fieldset th:replace="~{fieldset/fieldset :: fieldset(${form.fieldset()})}"></fieldset></form></th:block>
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2017-2023 original authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.views.fields.tck;

import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.micronaut.views.ViewsRenderer;
import io.micronaut.views.fields.Fieldset;
import io.micronaut.views.fields.Form;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.util.Collections;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;


@SuppressWarnings({"java:S5960"}) // Assertions are fine, these are tests
@MicronautTest(startApplication = false)
class FormEncTypeRenderTest {

@Test
void render(ViewsRenderer<Map<String, Object>, ?> viewsRenderer) throws IOException {
assertNotNull(viewsRenderer);
Form form = new Form("/foo/bar", "post", new Fieldset(Collections.emptyList(), Collections.emptyList()), "application/x-www-form-urlencoded");
assertEquals("""
<form action="/foo/bar" method="post" enctype="application/x-www-form-urlencoded">\
</form>""",
TestUtils.render("fieldset/form.html", viewsRenderer, Map.of("form", form)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.util.CollectionUtils;
import io.micronaut.views.fields.messages.Message;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;

import java.util.List;

Expand All @@ -34,8 +36,8 @@
*/
@Experimental
@Introspected
public record Fieldset(@NonNull List<? extends FormElement> fields,
@NonNull List<Message> errors) {
public record Fieldset(@NonNull @NotEmpty List<@Valid ? extends FormElement> fields,
@NonNull List<@Valid Message> errors) {

/**
*
Expand Down
53 changes: 49 additions & 4 deletions views-fieldset/src/main/java/io/micronaut/views/fields/Form.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,63 @@
*/
package io.micronaut.views.fields;

import io.micronaut.core.annotation.Experimental;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.*;
import io.micronaut.views.fields.constraints.EnctypePostRequired;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;

/**
* Representation of an HTML form.
* @author Sergio del Amo
* @since 4.1.0
* @param action Form Action
* @param method Form Method. For example `post`
* @param method Form Method. either `get` or `post`
* @param fieldset Form fields
* @param enctype how the form-data should be encoded when submitting it to the server
*/
@Experimental
@EnctypePostRequired
@Introspected
public record Form(String action, String method, Fieldset fieldset) {
public record Form(@NonNull @NotBlank String action,
@NonNull @NotBlank @Pattern(regexp = "get|post") String method,
@NonNull @NotNull @Valid Fieldset fieldset,
@Nullable @Pattern(regexp = "application/x-www-form-urlencoded|multipart/form-data|text/plain") String enctype) {

private static final String POST = "post";

/**
*
* @param action Form Action
* @param method Form Method. either `get` or `post`
* @param fieldset Form fields
*/
public Form(@NonNull String action,
@NonNull String method,
@NonNull Fieldset fieldset) {
this(action, method, fieldset, null);
}

/**
*
* @param action Form Action
* @param fieldset Form fields
*/
public Form(@NonNull String action,
@NonNull Fieldset fieldset) {
this(action, POST, fieldset, null);
}

/**
*
* @param action Form Action
* @param fieldset Form fields
* @param enctype how the form-data should be encoded when submitting it to the server
*/
public Form(@NonNull String action,
@NonNull Fieldset fieldset,
@Nullable String enctype) {
this(action, POST, fieldset, enctype);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2017-2023 original authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.views.fields.constraints;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* The annotated {@link io.micronaut.views.fields.Form} must not have an encytpe declared if the method is not post.
* @author Sergio del Amo
* @since 5.1.0
*
*/
@Constraint(validatedBy = EnctypePostRequiredValidator.class)
@Target({ElementType.TYPE, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface EnctypePostRequired {
String MESSAGE = "io.micronaut.views.fields.constraints.EnctypePostRequired.message";

String message() default "{" + MESSAGE + "}";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2017-2023 original authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.views.fields.constraints;

import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.validation.validator.constraints.ConstraintValidator;
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext;
import io.micronaut.views.fields.Form;

/**
* Validator for the constraint {@link EnctypePostRequired} being applied to a {@link Form}.
* @author Sergio del Amo
* @since 5.1.0
*/
@Introspected
public class EnctypePostRequiredValidator implements ConstraintValidator<EnctypePostRequired, Form> {

private static final String METHOD_POST = "post";

@Override
public boolean isValid(@Nullable Form form,
@NonNull AnnotationValue<EnctypePostRequired> annotationMetadata,
@NonNull ConstraintValidatorContext context) {
if (form == null) {
return true;
}
return form.enctype() == null || form.method().equals(METHOD_POST);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2017-2023 original authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.views.fields.constraints;

import io.micronaut.context.StaticMessageSource;
import io.micronaut.core.annotation.Internal;
import jakarta.inject.Singleton;

/**
* Messages contributed by Form validation annotations.
* @author Sergio del Amo
* @since 5.1.0
*/
@Singleton
@Internal
class FormMessages extends StaticMessageSource {
private static final String ENCTYPE_POST_REQUIRED_MESSAGE = "enctype attribute can be used only if method equals post";

private static final String MESSAGE_SUFFIX = ".message";

FormMessages() {
addMessage(EnctypePostRequired.class.getName() + MESSAGE_SUFFIX, ENCTYPE_POST_REQUIRED_MESSAGE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright 2017-2023 original authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Custom Constraints for validating HTML Forms.
* @author Sergio del Amo
* @since 5.1.0
*/
package io.micronaut.views.fields.constraints;
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
package io.micronaut.views.fields.messages;

import io.micronaut.core.annotation.Experimental;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.beans.BeanProperty;
import io.micronaut.core.util.StringUtils;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.constraints.NotBlank;

import java.util.ArrayList;
import java.util.List;
Expand All @@ -33,7 +35,9 @@
* @param code The i18n code which can be used to fetch a localized message.
*/
@Experimental
public record Message(@NonNull String defaultMessage, @Nullable String code) implements Comparable<Message> {
@Introspected
public record Message(@NonNull @NotBlank String defaultMessage,
@Nullable String code) implements Comparable<Message> {
private static final String REGEX = "(.)([A-Z])";
private static final String REPLACEMENT = "$1 $2";
private static final String DOT = ".";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,60 @@
package io.micronaut.views.fields;

import io.micronaut.core.beans.BeanIntrospection;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.micronaut.validation.validator.Validator;
import io.micronaut.views.fields.elements.InputSubmitFormElement;
import jakarta.validation.ConstraintViolation;
import org.junit.jupiter.api.Test;

import java.util.Collections;
import java.util.Set;

import static org.junit.jupiter.api.Assertions.*;

@MicronautTest(startApplication = false)
class FormTest {
@Test
void isAnnotatedWithIntrospected() {
assertDoesNotThrow(() -> BeanIntrospection.getIntrospection(Form.class));
}

@Test
void formValidation(Validator validator) {
Fieldset fieldset = new Fieldset(Collections.singletonList(new InputSubmitFormElement(FormGenerator.SUBMIT)), Collections.emptyList());
// action cannot be an empty string
assertFalse(validator.validate(new Form("", "post", fieldset, "application/x-www-form-urlencoded")).isEmpty());
// action cannot be null
assertFalse(validator.validate(new Form(null, "post", fieldset, "application/x-www-form-urlencoded")).isEmpty());

// method cannot be an empty string
assertFalse(validator.validate(new Form("/foo/bar", "", fieldset, null)).isEmpty());
// method cannot be null
assertFalse(validator.validate(new Form("/foo/bar", null, fieldset, null)).isEmpty());
// method can only be get or post
assertFalse(validator.validate(new Form("/foo/bar", "put", fieldset, null)).isEmpty());

//method cannot be get if enctype
Set<ConstraintViolation<Form>> violations = validator.validate(new Form("/foo/bar", "get", fieldset, "application/x-www-form-urlencoded"));
assertFalse(violations.isEmpty());
assertEquals(Collections.singletonList("enctype attribute can be used only if method equals post"), violations.stream().map(ConstraintViolation::getMessage).toList());

// fieldset cannot be null
Fieldset invalidFieldset = new Fieldset(Collections.emptyList(), Collections.emptyList());
assertFalse(validator.validate(new Form("/foo/bar", "post", invalidFieldset, "application/x-www-form-urlencoded")).isEmpty());

// fieldset must be valid
assertFalse(validator.validate(new Form("/foo/bar", "post", null, "application/x-www-form-urlencoded")).isEmpty());

// enctype has to be form-url-enconded form-data or txt
assertFalse(validator.validate(new Form("/foo/bar", "post", fieldset, "text/html")).isEmpty());

// enctype can be null
assertTrue(validator.validate(new Form("/foo/bar", "post", fieldset, null)).isEmpty());
assertTrue(validator.validate(new Form("/foo/bar", "post", fieldset, "application/x-www-form-urlencoded")).isEmpty());
assertTrue(validator.validate(new Form("/foo/bar", "post", fieldset, "multipart/form-data")).isEmpty());
assertTrue(validator.validate(new Form("/foo/bar", "post", fieldset, "text/plain")).isEmpty());
assertTrue(validator.validate(new Form("/foo/bar", "get", fieldset, null)).isEmpty());

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.micronaut.views.fields.messages;

import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.micronaut.validation.validator.Validator;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

@MicronautTest(startApplication = false)
class MessageTest {

@Test
void messageValidation(Validator validator) {
assertTrue(validator.validate(Message.of("Foo")).isEmpty());
assertTrue(validator.validate(Message.of("Foo", "foo.code")).isEmpty());
assertFalse(validator.validate(Message.of("")).isEmpty());
String msg = null;
assertFalse(validator.validate(Message.of(msg)).isEmpty());
}
}

0 comments on commit 2e01053

Please sign in to comment.