1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.core.metapath.format;
7   
8   import org.apache.logging.log4j.LogManager;
9   import org.apache.logging.log4j.Logger;
10  
11  import java.util.List;
12  
13  import dev.metaschema.core.metapath.item.node.IAssemblyInstanceGroupedNodeItem;
14  import dev.metaschema.core.metapath.item.node.IAssemblyNodeItem;
15  import dev.metaschema.core.metapath.item.node.IDocumentNodeItem;
16  import dev.metaschema.core.metapath.item.node.IFieldNodeItem;
17  import dev.metaschema.core.metapath.item.node.IFlagNodeItem;
18  import dev.metaschema.core.metapath.item.node.IModelNodeItem;
19  import dev.metaschema.core.metapath.item.node.IModuleNodeItem;
20  import dev.metaschema.core.metapath.item.node.IRootAssemblyNodeItem;
21  import dev.metaschema.core.model.IFlagInstance;
22  import dev.metaschema.core.model.INamedModelInstance;
23  import dev.metaschema.core.model.JsonGroupAsBehavior;
24  import dev.metaschema.core.qname.IEnhancedQName;
25  import dev.metaschema.core.util.ObjectUtils;
26  import edu.umd.cs.findbugs.annotations.NonNull;
27  
28  /**
29   * An {@link IPathFormatter} that produces RFC 6901 compliant JSON Pointer
30   * paths.
31   * <p>
32   * This formatter produces paths suitable for use with JSON tooling and
33   * JSON-based error reporting. The format follows the JSON Pointer specification
34   * (RFC 6901).
35   * <p>
36   * Example output: {@code /catalog/controls/0/id}
37   * <p>
38   * Key characteristics:
39   * <ul>
40   * <li>Uses JSON property names (not XML element names)</li>
41   * <li>Uses 0-based array indices for LIST grouping</li>
42   * <li>Uses key values for KEYED grouping</li>
43   * <li>Handles SINGLETON_OR_LIST by checking sibling count</li>
44   * <li>Escapes special characters per RFC 6901 (~ as ~0, / as ~1)</li>
45   * <li>No @ prefix for flags (unlike XPath)</li>
46   * </ul>
47   *
48   * @see <a href="https://www.rfc-editor.org/rfc/rfc6901">RFC 6901 - JSON
49   *      Pointer</a>
50   */
51  public class JsonPointerFormatter implements IPathFormatter {
52    private static final Logger LOGGER = LogManager.getLogger(JsonPointerFormatter.class);
53  
54    @Override
55    @NonNull
56    public String formatMetaschema(IModuleNodeItem metaschema) {
57      // Returns empty string to produce leading "/" via join in format method
58      return "";
59    }
60  
61    @Override
62    @NonNull
63    public String formatDocument(IDocumentNodeItem document) {
64      // Returns empty string to produce leading "/" via join in format method
65      return "";
66    }
67  
68    @Override
69    @NonNull
70    public String formatRootAssembly(IRootAssemblyNodeItem root) {
71      String jsonName = root.getDefinition().getJsonName();
72      return escapeJsonPointer(jsonName);
73    }
74  
75    @Override
76    @NonNull
77    public String formatAssembly(IAssemblyNodeItem assembly) {
78      return formatModelItem(assembly);
79    }
80  
81    @Override
82    @NonNull
83    public String formatAssembly(IAssemblyInstanceGroupedNodeItem assembly) {
84      return formatModelItem(assembly);
85    }
86  
87    @Override
88    @NonNull
89    public String formatField(IFieldNodeItem field) {
90      return formatModelItem(field);
91    }
92  
93    @Override
94    @NonNull
95    public String formatFlag(IFlagNodeItem flag) {
96      // JSON Pointer does not use @ prefix for attributes
97      return escapeJsonPointer(flag.getQName().getLocalName());
98    }
99  
100   /**
101    * Format a model node item (assembly or field) based on its JSON grouping
102    * behavior.
103    *
104    * @param item
105    *          the model node item to format
106    * @return the formatted path segment
107    */
108   @NonNull
109   private static String formatModelItem(@NonNull IModelNodeItem<?, ?> item) {
110     INamedModelInstance instance = item.getInstance();
111     if (instance == null) {
112       // No instance - use local name only
113       return escapeJsonPointer(item.getQName().getLocalName());
114     }
115 
116     String jsonName = escapeJsonPointer(instance.getJsonName());
117     JsonGroupAsBehavior behavior = instance.getJsonGroupAsBehavior();
118 
119     switch (behavior) {
120     case KEYED:
121       String keyValue = getJsonKeyValue(item, instance);
122       return jsonName + "/" + escapeJsonPointer(keyValue);
123     case LIST:
124       // 0-based index
125       return jsonName + "/" + (item.getPosition() - 1);
126     case SINGLETON_OR_LIST:
127       int siblingCount = countSiblings(item);
128       if (siblingCount > 1) {
129         // Multiple siblings - use array notation
130         return jsonName + "/" + (item.getPosition() - 1);
131       }
132       // Single sibling - no index
133       return jsonName;
134     case NONE:
135     default:
136       return jsonName;
137     }
138   }
139 
140   /**
141    * Get the JSON key value for a KEYED collection item.
142    *
143    * @param item
144    *          the model node item
145    * @param instance
146    *          the model instance
147    * @return the key value, or falls back to 0-based index if not available
148    */
149   @NonNull
150   private static String getJsonKeyValue(
151       @NonNull IModelNodeItem<?, ?> item,
152       @NonNull INamedModelInstance instance) {
153     IFlagInstance keyFlag = instance.getEffectiveJsonKey();
154     if (keyFlag != null) {
155       IEnhancedQName keyFlagQName = keyFlag.getQName();
156       IFlagNodeItem flagItem = item.getFlagByName(keyFlagQName);
157       if (flagItem != null) {
158         return flagItem.toAtomicItem().asString();
159       }
160     }
161     // Fallback to 0-based index - this indicates a potential issue with the model
162     // or data
163     if (LOGGER.isWarnEnabled()) {
164       LOGGER.warn("Unable to resolve JSON key for KEYED collection item '{}', falling back to numeric index",
165           item.getQName().getLocalName());
166     }
167     return ObjectUtils.notNull(String.valueOf(item.getPosition() - 1));
168   }
169 
170   /**
171    * Count the number of siblings with the same name as the given item.
172    *
173    * @param item
174    *          the model node item
175    * @return the sibling count (including the item itself)
176    */
177   private static int countSiblings(@NonNull IModelNodeItem<?, ?> item) {
178     IAssemblyNodeItem parent = item.getParentContentNodeItem();
179     if (parent == null) {
180       return 1;
181     }
182     List<? extends IModelNodeItem<?, ?>> siblings = parent.getModelItemsByName(item.getQName());
183     return siblings.size();
184   }
185 
186   /**
187    * Escape a string value according to RFC 6901.
188    * <p>
189    * The order of escaping is important: ~ must be escaped first, then /.
190    *
191    * @param value
192    *          the value to escape
193    * @return the escaped value
194    */
195   @NonNull
196   private static String escapeJsonPointer(@NonNull String value) {
197     // Order matters: escape ~ first, then /
198     return ObjectUtils.notNull(value.replace("~", "~0").replace("/", "~1"));
199   }
200 }