001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.server.view.thymeleaf;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020import static org.apache.juneau.commons.utils.StringUtils.*;
021
022import java.util.*;
023
024import org.apache.juneau.rest.server.view.*;
025
026/**
027 * Immutable value class returned from {@code @RestOp}-annotated methods to ask the framework to
028 * render a Thymeleaf template.
029 *
030 * <p>
031 * Companion to {@link ThymeleafMixin} and {@link ThymeleafViewRenderer}: the mixin sets up
032 * the {@code /thymeleaf/*} mount and registers the renderer; the renderer detects
033 * {@code ThymeleafView} returns in the response-processor chain and asks the configured
034 * {@code org.thymeleaf.TemplateEngine} to {@code process(templateName, context, writer)} directly
035 * onto the response writer.
036 *
037 * <h5 class='figure'>Usage:</h5>
038 *
039 * <p class='bjava'>
040 *    <ja>@RestGet</ja>(<js>"/hello/{name}"</js>)
041 *    <jk>public</jk> View hello(<ja>@Path</ja> String <jv>name</jv>) {
042 *       <jk>return</jk> ThymeleafView.<jsm>of</jsm>(<js>"hello"</js>)
043 *          .attr(<js>"name"</js>, <jv>name</jv>)
044 *          .attr(<js>"ts"</js>, Instant.<jsm>now</jsm>());
045 *    }
046 * </p>
047 *
048 * <p>
049 * The template name is engine-relative &mdash; the configured
050 * {@code ClassLoaderTemplateResolver} (or whatever resolver the active
051 * {@code org.thymeleaf.TemplateEngine} carries) prepends its own prefix and appends its own suffix
052 * (typically {@code .html}). With the bridge's default engine + {@code basePath("/templates/")},
053 * {@code ThymeleafView.of("hello")} resolves to {@code /templates/hello.html} on the classpath.
054 *
055 * <h5 class='section'>Immutability:</h5>
056 *
057 * <p>
058 * Each {@code attr(...)} / {@code attrs(...)} / {@code header(...)} call returns a <b>new</b>
059 * {@code ThymeleafView} carrying the additional binding; the original instance is unchanged. This
060 * keeps {@code ThymeleafView} a value type safe to share across requests (e.g. as a static
061 * singleton built once and returned from many handlers).
062 *
063 * <h5 class='section'>See Also:</h5><ul>
064 *    <li class='jc'>{@link ThymeleafMixin}
065 *    <li class='jc'>{@link ThymeleafViewRenderer}
066 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/ThymeleafViewSupport">Thymeleaf View Support</a>
067 * </ul>
068 *
069 * @since 10.0.0
070 */
071public final class ThymeleafView implements View {
072
073   private final String templateName;
074   private final Map<String,Object> attributes;
075   private final Map<String,String> responseHeaders;
076
077   /**
078    * Creates a new {@code ThymeleafView} carrying the given template name and no attributes.
079    *
080    * <p>
081    * The template name is engine-relative; the configured
082    * {@code org.thymeleaf.TemplateEngine} resolver supplies the prefix and suffix. For the
083    * bridge's default engine: {@code "hello"} resolves to {@code <basePath>hello.html}.
084    *
085    * @param templateName The template name (engine-relative, no extension). Must not be
086    *    {@code null} or blank.
087    * @return A new {@code ThymeleafView} instance.
088    * @throws IllegalArgumentException If {@code templateName} is {@code null} or blank.
089    */
090   public static ThymeleafView of(String templateName) {
091      if (isBlank(templateName))
092         throw iaex("templateName must not be null or blank");
093      return new ThymeleafView(templateName, Map.of(), Map.of());
094   }
095
096   private ThymeleafView(String templateName, Map<String,Object> attributes, Map<String,String> responseHeaders) {
097      this.templateName = templateName;
098      this.attributes = attributes;
099      this.responseHeaders = responseHeaders;
100   }
101
102   /**
103    * Returns a copy of this view with the given attribute added (or replaced if a binding for
104    * {@code key} already exists).
105    *
106    * <p>
107    * Each attribute is exposed to the template as a named variable (e.g.
108    * {@code th:text="${name}"} resolves the {@code "name"} key from the populated
109    * {@code Context}). {@code null} values are rejected at build-time because a
110    * Thymeleaf {@code Context} silently drops {@code null}-valued bindings, masking what is
111    * typically a caller bug; rejecting up-front surfaces the mistake at the call site.
112    *
113    * @param key The attribute key (exposed inside the template as {@code ${key}}). Must not be
114    *    {@code null} or blank.
115    * @param value The attribute value. Must not be {@code null}.
116    * @return A new {@code ThymeleafView} carrying the additional attribute.
117    * @throws IllegalArgumentException If {@code key} is {@code null} or blank, or {@code value}
118    *    is {@code null}.
119    */
120   public ThymeleafView attr(String key, Object value) {
121      if (isBlank(key))
122         throw iaex("attribute key must not be null or blank");
123      if (value == null)
124         throw iaex("attribute value must not be null (attribute '%s')", key);
125      var copy = new LinkedHashMap<>(attributes);
126      copy.put(key, value);
127      return new ThymeleafView(templateName, Map.copyOf(copy), responseHeaders);
128   }
129
130   /**
131    * Returns a copy of this view with all entries from the given map added (or replaced if a
132    * binding for any key already exists).
133    *
134    * @param values The attributes to add. {@code null} is treated as an empty map (the result is
135    *    the same instance with no entries added).
136    * @return A new {@code ThymeleafView} carrying the additional attributes.
137    * @throws IllegalArgumentException If any entry has a {@code null}/blank key or a {@code null}
138    *    value.
139    */
140   public ThymeleafView attrs(Map<String,?> values) {
141      if (ie(values))
142         return this;
143      var copy = new LinkedHashMap<>(attributes);
144      values.forEach((k, v) -> {
145         if (isBlank(k))
146            throw iaex("attribute key must not be null or blank");
147         if (v == null)
148            throw iaex("attribute value must not be null (attribute '%s')", k);
149         copy.put(k, v);
150      });
151      return new ThymeleafView(templateName, Map.copyOf(copy), responseHeaders);
152   }
153
154   /**
155    * Returns a copy of this view with the given response header set on the rendered response.
156    *
157    * <p>
158    * Typical usage: {@code .header("Content-Type", "text/html; charset=UTF-8")} or
159    * {@code .header("Cache-Control", "no-store")}. The renderer applies these headers via
160    * {@link jakarta.servlet.http.HttpServletResponse#setHeader(String, String)
161    * HttpServletResponse.setHeader(...)} before writing the rendered output.
162    *
163    * @param name The header name. Must not be {@code null} or blank.
164    * @param value The header value. Must not be {@code null}.
165    * @return A new {@code ThymeleafView} carrying the additional response header.
166    * @throws IllegalArgumentException If {@code name} is {@code null} or blank, or {@code value}
167    *    is {@code null}.
168    */
169   public ThymeleafView header(String name, String value) {
170      if (isBlank(name))
171         throw iaex("header name must not be null or blank");
172      if (value == null)
173         throw iaex("header value must not be null (header '%s')", name);
174      var copy = new LinkedHashMap<>(responseHeaders);
175      copy.put(name, value);
176      return new ThymeleafView(templateName, attributes, Map.copyOf(copy));
177   }
178
179   @Override /* Overridden from View */
180   public String getTemplateName() {
181      return templateName;
182   }
183
184   @Override /* Overridden from View */
185   public Map<String,Object> getAttributes() {
186      return attributes;
187   }
188
189   @Override /* Overridden from View */
190   public Map<String,String> getResponseHeaders() {
191      return responseHeaders;
192   }
193
194   @Override /* Overridden from Object */
195   public String toString() {
196      return "ThymeleafView[template=" + templateName + ", attrs=" + attributes.keySet()
197         + ", headers=" + responseHeaders.keySet() + "]";
198   }
199}