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.console;
018
019import static java.nio.charset.StandardCharsets.*;
020
021import java.io.*;
022import java.util.*;
023
024import org.apache.juneau.marshall.collections.*;
025
026/**
027 * Loads {@code juneau-page.schema.json} from the classpath and validates contracts against it, plus
028 * the cross-reference rules R-1..R-4 the schema cannot express. Used by {@code PageCapture} in dev
029 * mode and by {@code PageContractAssert} in tests.
030 *
031 * <h5 class='section'>Example:</h5>
032 * <p class='bjava'>
033 *    List&lt;String&gt; <jv>errors</jv> = PageContractSchema.<jsm>get</jsm>().validate(<jv>contractJson</jv>);
034 *    <jk>if</jk> (! <jv>errors</jv>.isEmpty())
035 *       <jk>throw new</jk> IllegalStateException(String.<jsm>join</jsm>(<js>"\n"</js>, <jv>errors</jv>));
036 *
037 *    <jc>// With the &lt;template&gt; ids found in the rendered page, R-4 is checked too:</jc>
038 *    <jv>errors</jv> = PageContractSchema.<jsm>get</jsm>().validate(<jv>contractJson</jv>, Set.<jsm>of</jsm>(<js>"jc-seg-1"</js>, <js>"header.banner"</js>));
039 * </p>
040 *
041 * @since 10.0.0
042 */
043@SuppressWarnings({
044   "unchecked" // validate() casts the parsed JSON contract (nav, cards, activeNav) to List/Map, after the schema validator has checked those shapes
045})
046public final class PageContractSchema {
047
048   /** Classpath location of the schema. */
049   public static final String RESOURCE = "org/apache/juneau/console/juneau-page.schema.json";
050
051   private static final PageContractSchema INSTANCE = new PageContractSchema();
052
053   private final String schemaJson;
054   private final SchemaSubsetValidator validator;
055
056   private PageContractSchema() {
057      try (var in = PageContractSchema.class.getClassLoader().getResourceAsStream(RESOURCE)) {
058         if (in == null)
059            throw new IllegalStateException("Missing classpath resource: " + RESOURCE);
060         schemaJson = new String(in.readAllBytes(), UTF_8);
061         validator = new SchemaSubsetValidator(JsonMap.ofString(schemaJson));
062      } catch (IOException e) {
063         throw new UncheckedIOException(e);
064      } catch (Exception e) {
065         throw new IllegalStateException("Unparseable " + RESOURCE, e);
066      }
067   }
068
069   /**
070    * Returns the shared instance.
071    *
072    * @return The shared instance, never <jk>null</jk>.
073    */
074   public static PageContractSchema get() {
075      return INSTANCE;
076   }
077
078   /**
079    * Returns the schema text exactly as shipped.
080    *
081    * @return The schema JSON.
082    */
083   public String schemaJson() {
084      return schemaJson;
085   }
086
087   /**
088    * Validates a contract against the schema and rules R-1..R-3.
089    *
090    * @param contractJson The contract JSON.
091    * @return The findings, empty when valid.
092    */
093   public List<String> validate(String contractJson) {
094      return validate(contractJson, null);
095   }
096
097   /**
098    * Validates a contract against the schema and rules R-1..R-4.
099    *
100    * @param contractJson The contract JSON.
101    * @param templateIds The {@code <template data-card|data-slot>} ids present in the page, or <jk>null</jk> to skip R-4.
102    * @return The findings, empty when valid.
103    */
104   @SuppressWarnings({
105      "java:S3776" // Rules R-1..R-4 read best as one straight-line pass.
106   })
107   public List<String> validate(String contractJson, Set<String> templateIds) {
108      Map<String,Object> c;
109      try {
110         c = JsonMap.ofString(contractJson);
111      } catch (Exception e) {
112         return List.of("$: unparseable JSON: " + e.getMessage());
113      }
114      var errors = new ArrayList<>(validator.validate(c));
115      if (! errors.isEmpty())
116         return errors;
117      var nav = (List<Object>)c.get("nav");
118      checkSiblingIds(nav, "nav", errors);                                   // R-1
119      var cardIds = new HashSet<String>();
120      for (var o : (List<Object>)c.get("cards")) {                           // R-2
121         var id = (String)((Map<String,Object>)o).get("id");
122         if (! cardIds.add(id))
123            errors.add("R-2: duplicate card id '" + id + "'");
124      }
125      var level = nav;                                                        // R-3
126      for (var id : (List<String>)c.get("activeNav")) {
127         var next = level == null ? null : level.stream().map(x -> (Map<String,Object>)x)
128            .filter(n -> id.equals(n.get("id"))).findFirst().orElse(null);
129         if (next == null) {
130            errors.add("R-3: activeNav " + c.get("activeNav") + " is not a path in nav (failed at '" + id + "')");
131            break;
132         }
133         level = (List<Object>)next.get("children");
134      }
135      if (templateIds != null)                                                // R-4
136         for (var ref : templateRefs(c))
137            if (! templateIds.contains(ref))
138               errors.add("R-4: template '" + ref + "' is referenced but not present");
139      return errors;
140   }
141
142   private static void checkSiblingIds(List<Object> nodes, String where, List<String> errors) {
143      if (nodes == null)
144         return;
145      var seen = new HashSet<String>();
146      for (var o : nodes) {
147         var n = (Map<String,Object>)o;
148         var id = (String)n.get("id");
149         if (! seen.add(id))
150            errors.add("R-1: duplicate nav id '" + id + "' under " + where);
151         checkSiblingIds((List<Object>)n.get("children"), where + "/" + id, errors);
152      }
153   }
154
155   static List<String> templateRefs(Map<String,Object> c) {
156      var refs = new ArrayList<String>();
157      for (var scope : List.of("header", "footer")) {
158         var sec = (Map<String,Object>)c.get(scope);
159         if (sec != null && sec.get("slots") instanceof Map<?,?> slots)
160            for (var v : slots.values())
161               refs.add((String)v);
162      }
163      for (var o : (List<Object>)c.get("cards")) {
164         var t = ((Map<String,Object>)o).get("template");
165         if (t != null)
166            refs.add((String)t);
167      }
168      return refs;
169   }
170}