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.freemarker;
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 an Apache FreeMarker template.
029 *
030 * <p>
031 * Companion to {@link FreemarkerMixin} and {@link FreemarkerViewRenderer}: the mixin sets
032 * up the {@code /freemarker/*} mount and registers the renderer; the renderer detects
033 * {@code FreemarkerView} returns in the response-processor chain and asks the configured
034 * {@code freemarker.template.Configuration} to {@code getTemplate(templateName)} and then
035 * {@code process(dataModel, writer)} directly 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> FreemarkerView.<jsm>of</jsm>(<js>"hello.ftlh"</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 configuration-relative &mdash; the configured
050 * {@code freemarker.template.Configuration} template loader prepends its own resource root. With
051 * the bridge's default configuration + {@code basePath("/templates/")},
052 * {@code FreemarkerView.of("hello.ftlh")} resolves to {@code /templates/hello.ftlh} on the
053 * classpath.
054 *
055 * <h5 class='section'>{@code .ftl} vs {@code .ftlh} auto-escape:</h5>
056 *
057 * <p>
058 * FreeMarker auto-selects HTML escaping by file extension: {@code .ftlh} templates emit
059 * HTML-escaped output (variable references are escaped automatically), while {@code .ftl}
060 * templates emit raw output. For HTML responses, prefer the {@code .ftlh} extension to avoid an
061 * XSS regression sneaking in via a future attribute-binding change.
062 *
063 * <h5 class='section'>Template suffix:</h5>
064 *
065 * <p>
066 * The {@link FreemarkerMixin.Builder#templateSuffix(String) templateSuffix(...)} builder
067 * knob lets the bridge append a suffix (e.g. {@code ".ftlh"}) to template names that don't
068 * already end with it, so callers can write {@code FreemarkerView.of("hello")} and have the
069 * bridge resolve it as {@code "hello.ftlh"} on the classpath (idempotent &mdash; explicit suffix
070 * is honored).
071 *
072 * <h5 class='section'>Immutability:</h5>
073 *
074 * <p>
075 * Each {@code attr(...)} / {@code attrs(...)} / {@code header(...)} call returns a <b>new</b>
076 * {@code FreemarkerView} carrying the additional binding; the original instance is unchanged.
077 * This keeps {@code FreemarkerView} a value type safe to share across requests (e.g. as a static
078 * singleton built once and returned from many handlers).
079 *
080 * <h5 class='section'>See Also:</h5><ul>
081 *    <li class='jc'>{@link FreemarkerMixin}
082 *    <li class='jc'>{@link FreemarkerViewRenderer}
083 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/FreemarkerViewSupport">FreeMarker View Support</a>
084 * </ul>
085 *
086 * @since 10.0.0
087 */
088public final class FreemarkerView implements View {
089
090   private final String templateName;
091   private final Map<String,Object> attributes;
092   private final Map<String,String> responseHeaders;
093
094   /**
095    * Creates a new {@code FreemarkerView} carrying the given template name and no attributes.
096    *
097    * <p>
098    * The template name is configuration-relative; the configured
099    * {@code freemarker.template.Configuration} template loader supplies the prefix (resource
100    * root). With the bridge's default configuration + {@code basePath("/templates/")}, the name
101    * {@code "hello.ftlh"} resolves to {@code /templates/hello.ftlh} on the classpath.
102    *
103    * @param templateName The template name (configuration-relative, suffix optional when the
104    *    mixin's {@link FreemarkerMixin.Builder#templateSuffix(String) templateSuffix} is
105    *    configured). Must not be {@code null} or blank.
106    * @return A new {@code FreemarkerView} instance.
107    * @throws IllegalArgumentException If {@code templateName} is {@code null} or blank.
108    */
109   public static FreemarkerView of(String templateName) {
110      if (isBlank(templateName))
111         throw iaex("templateName must not be null or blank");
112      return new FreemarkerView(templateName, Map.of(), Map.of());
113   }
114
115   private FreemarkerView(String templateName, Map<String,Object> attributes, Map<String,String> responseHeaders) {
116      this.templateName = templateName;
117      this.attributes = attributes;
118      this.responseHeaders = responseHeaders;
119   }
120
121   /**
122    * Returns a copy of this view with the given attribute added (or replaced if a binding for
123    * {@code key} already exists).
124    *
125    * <p>
126    * The full attribute map is passed to FreeMarker as the data model, so each entry is
127    * accessible inside the template as {@code ${key}}. {@code null} values are rejected at
128    * build-time because FreeMarker's default null-handling renders missing/null bindings as an
129    * error or empty string depending on configuration, which typically masks what is a caller
130    * bug; rejecting up-front surfaces the mistake at the call site (matches the
131    * Thymeleaf / Mustache bridge precedents).
132    *
133    * @param key The attribute key (exposed inside the template as {@code ${key}}). Must not be
134    *    {@code null} or blank.
135    * @param value The attribute value. Must not be {@code null}.
136    * @return A new {@code FreemarkerView} carrying the additional attribute.
137    * @throws IllegalArgumentException If {@code key} is {@code null} or blank, or {@code value}
138    *    is {@code null}.
139    */
140   public FreemarkerView attr(String key, Object value) {
141      if (isBlank(key))
142         throw iaex("attribute key must not be null or blank");
143      if (value == null)
144         throw iaex("attribute value must not be null (attribute '%s')", key);
145      var copy = new LinkedHashMap<>(attributes);
146      copy.put(key, value);
147      return new FreemarkerView(templateName, Collections.unmodifiableMap(copy), responseHeaders);
148   }
149
150   /**
151    * Returns a copy of this view with all entries from the given map added (or replaced if a
152    * binding for any key already exists).
153    *
154    * @param values The attributes to add. {@code null} is treated as an empty map (the result is
155    *    the same instance with no entries added).
156    * @return A new {@code FreemarkerView} carrying the additional attributes.
157    * @throws IllegalArgumentException If any entry has a {@code null}/blank key or a {@code null}
158    *    value.
159    */
160   public FreemarkerView attrs(Map<String,?> values) {
161      if (ie(values))
162         return this;
163      var copy = new LinkedHashMap<>(attributes);
164      values.forEach((k, v) -> {
165         if (isBlank(k))
166            throw iaex("attribute key must not be null or blank");
167         if (v == null)
168            throw iaex("attribute value must not be null (attribute '%s')", k);
169         copy.put(k, v);
170      });
171      return new FreemarkerView(templateName, Collections.unmodifiableMap(copy), responseHeaders);
172   }
173
174   /**
175    * Returns a copy of this view with the given response header set on the rendered response.
176    *
177    * <p>
178    * Typical usage: {@code .header("Content-Type", "text/html; charset=UTF-8")} or
179    * {@code .header("Cache-Control", "no-store")}. The renderer applies these headers via
180    * {@link jakarta.servlet.http.HttpServletResponse#setHeader(String, String)
181    * HttpServletResponse.setHeader(...)} before writing the rendered output.
182    *
183    * @param name The header name. Must not be {@code null} or blank.
184    * @param value The header value. Must not be {@code null}.
185    * @return A new {@code FreemarkerView} carrying the additional response header.
186    * @throws IllegalArgumentException If {@code name} is {@code null} or blank, or {@code value}
187    *    is {@code null}.
188    */
189   public FreemarkerView header(String name, String value) {
190      if (isBlank(name))
191         throw iaex("header name must not be null or blank");
192      if (value == null)
193         throw iaex("header value must not be null (header '%s')", name);
194      var copy = new LinkedHashMap<>(responseHeaders);
195      copy.put(name, value);
196      return new FreemarkerView(templateName, attributes, Map.copyOf(copy));
197   }
198
199   /**
200    * Marker for an attribute value that promises to be adopted during the render (unwrapped from the data model by
201    * some directive and applied to render-scoped state), and wants the render to fail if that never happens
202    * instead of silently behaving as if the attribute was absent.
203    *
204    * <p>
205    * {@link FreemarkerViewRenderer} checks every attribute value after a successful {@code Template.process(...)}:
206    * any value implementing this interface whose {@link #consumed()} is still {@code false} fails the render with
207    * {@link #notConsumedMessage(String)}.
208    */
209   public interface MustConsume {
210
211      /** @return Whether this attribute was adopted during the render. */
212      boolean consumed();
213
214      /**
215       * @param templateName The template that was rendered.
216       * @return The message to raise when {@link #consumed()} is still {@code false} after the render.
217       */
218      String notConsumedMessage(String templateName);
219   }
220
221   @Override /* Overridden from View */
222   public String getTemplateName() {
223      return templateName;
224   }
225
226   @Override /* Overridden from View */
227   public Map<String,Object> getAttributes() {
228      return attributes;
229   }
230
231   @Override /* Overridden from View */
232   public Map<String,String> getResponseHeaders() {
233      return responseHeaders;
234   }
235
236   @Override /* Overridden from Object */
237   public String toString() {
238      return "FreemarkerView[template=" + templateName + ", attrs=" + attributes.keySet()
239         + ", headers=" + responseHeaders.keySet() + "]";
240   }
241}