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<String> <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 <template> 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}