Skip to content

Commit e4e3779

Browse files
authored
feat: add flat JSON cache for ClassApi tree and minor fixes (v9.1.7) (#60)
- Add FlatResourceInfo/FlatMethodInfo/FlatParamInfo records for compact JSON serialization - Add BuildFlatCache, LoadFlatCache, BuildClassApiFromFlat to GeneratorClassApi - Add internal constructors on ClassApi, MethodApi, ParameterApi to rebuild from flat cache - Add GetMethodParameters and GetMethodParameterEnumValues helpers to ApiExplorerHelper - Fix null reference in RenderValue when param not found - Fix timestamp/timestamp_gmt rendering to convert Unix seconds to readable date - Add optionStyle parameter to Usage() for --flag <type> format - Add IsExternalInit shim for netstandard2.0 record support - Bump version to 9.1.7
1 parent d0e6d18 commit e4e3779

File tree

10 files changed

+291
-15
lines changed

10 files changed

+291
-15
lines changed

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<TargetFrameworks>net8.0;net9.0;net10.0;netstandard2.0</TargetFrameworks>
55
<LangVersion>latest</LangVersion>
66
<ImplicitUsings>enable</ImplicitUsings>
7-
<Version>9.1.6</Version>
7+
<Version>9.1.7</Version>
88
<!-- <NoWarn>$(NoWarn);CS1591;CS0436</NoWarn> -->
99
<Company>Corsinvest Srl</Company>
1010
<Authors>Corsinvest Srl</Authors>

src/Corsinvest.ProxmoxVE.Api.Extension/Utils/ApiExplorerHelper.cs

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,55 @@ public static string[] GetArgumentTags(string command)
279279
.Where(a => a.Success)
280280
.Select(a => a.Groups[1].Value)];
281281

282+
/// <summary>
283+
/// Get parameter names for a resource and method (excludes path keys).
284+
/// </summary>
285+
public static string[] GetMethodParameters(ClassApi classApiRoot, string resource, MethodType methodType)
286+
{
287+
var classApi = ClassApi.GetFromResource(classApiRoot, resource);
288+
if (classApi == null) { return []; }
289+
var humanized = methodType switch
290+
{
291+
MethodType.Get => "get",
292+
MethodType.Set => "put",
293+
MethodType.Create => "post",
294+
MethodType.Delete => "delete",
295+
_ => methodType.ToString().ToLower()
296+
};
297+
var method = classApi.Methods.FirstOrDefault(m =>
298+
string.Equals(m.MethodType, humanized, StringComparison.OrdinalIgnoreCase));
299+
if (method == null) { return []; }
300+
return [.. method.Parameters
301+
.Where(p => !classApi.Keys.Contains(p.Name))
302+
.Select(p => p.Name)];
303+
}
304+
305+
/// <summary>
306+
/// Get enum values for a specific parameter of a resource/method. Returns empty if not an enum.
307+
/// </summary>
308+
public static string[] GetMethodParameterEnumValues(ClassApi classApiRoot, string resource, MethodType methodType, string paramName)
309+
{
310+
var classApi = ClassApi.GetFromResource(classApiRoot, resource);
311+
if (classApi == null) { return []; }
312+
var humanized = methodType switch
313+
{
314+
MethodType.Get => "get",
315+
MethodType.Set => "put",
316+
MethodType.Create => "post",
317+
MethodType.Delete => "delete",
318+
_ => methodType.ToString().ToLower()
319+
};
320+
var method = classApi.Methods.FirstOrDefault(m =>
321+
string.Equals(m.MethodType, humanized, StringComparison.OrdinalIgnoreCase));
322+
if (method == null) { return []; }
323+
var param = method.Parameters.FirstOrDefault(p =>
324+
string.Equals(p.Name, paramName, StringComparison.OrdinalIgnoreCase));
325+
if (param == null) { return []; }
326+
if (param.EnumValues.Length > 0) { return param.EnumValues; }
327+
if (string.Equals(param.Type, "boolean", StringComparison.OrdinalIgnoreCase)) { return ["0", "1"]; }
328+
return [];
329+
}
330+
282331
/// <summary>
283332
/// Create parameter resource split ':'
284333
/// </summary>
@@ -475,7 +524,10 @@ private static object GetValue(object value, string key, List<ParameterApi> retu
475524
}
476525
else
477526
{
478-
return returnParameters.FirstOrDefault(a => a.Name == key).RendererValue(value);
527+
var param = returnParameters.FirstOrDefault(a => a.Name == key);
528+
return param != null
529+
? param.RendererValue(value)
530+
: (value is ExpandoObject || value is IList) ? JsonConvert.SerializeObject(value) : value;
479531
}
480532
}
481533

@@ -525,13 +577,15 @@ private static void CreateTable(IEnumerable<ParameterApi> parameters, StringBuil
525577
/// <param name="returnsType"></param>
526578
/// <param name="command"></param>
527579
/// <param name="verbose"></param>
580+
/// <param name="optionStyle"></param>
528581
/// <returns></returns>
529582
public static string Usage(ClassApi classApiRoot,
530583
string resource,
531584
TableGenerator.Output output,
532585
bool returnsType = false,
533586
string command = null,
534-
bool verbose = false)
587+
bool verbose = false,
588+
bool optionStyle = false)
535589
{
536590
var ret = new StringBuilder();
537591
var classApi = ClassApi.GetFromResource(classApiRoot, resource);
@@ -555,7 +609,8 @@ public static string Usage(ClassApi classApiRoot,
555609
//only parameters no keys
556610
var parameters = method.Parameters.Where(a => !classApi.Keys.Contains(a.Name));
557611

558-
var opts = string.Join(string.Empty, parameters.Where(a => !a.Optional).Select(a => $" {a.Name}:<{a.Type}>"));
612+
var opts = string.Join(string.Empty, parameters.Where(a => !a.Optional)
613+
.Select(a => optionStyle ? $" --{a.Name} <{a.Type}>" : $" {a.Name}:<{a.Type}>"));
559614
if (!string.IsNullOrWhiteSpace(opts)) { ret.Append(opts); }
560615

561616
//optional parameter

src/Corsinvest.ProxmoxVE.Api.Metadata/ClassApi.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ public class ClassApi
1818
/// </summary>
1919
public ClassApi() { IsRoot = true; }
2020

21+
/// <summary>
22+
/// Constructor for rebuilding from flat cache
23+
/// </summary>
24+
internal ClassApi(string resource, string name, bool isIndexed, ClassApi parent)
25+
{
26+
Resource = resource;
27+
Name = name;
28+
IsIndexed = isIndexed;
29+
Parent = parent;
30+
parent.SubClasses.Add(this);
31+
Keys.AddRange(parent.Keys);
32+
if (isIndexed) { Keys.Add(name.Replace("{", string.Empty).Replace("}", string.Empty)); }
33+
}
34+
2135
/// <summary>
2236
/// Constructor
2337
/// </summary>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright Corsinvest Srl
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
6+
namespace Corsinvest.ProxmoxVE.Api.Metadata;
7+
8+
/// <summary>Flat cache method info</summary>
9+
public record FlatMethodInfo(
10+
string? Comment,
11+
string? ReturnType,
12+
string? ReturnLinkHRef,
13+
FlatParamInfo[]? Params,
14+
FlatParamInfo[]? ReturnParams);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright Corsinvest Srl
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
6+
namespace Corsinvest.ProxmoxVE.Api.Metadata;
7+
8+
/// <summary>Flat cache parameter info</summary>
9+
public record FlatParamInfo(
10+
string Name,
11+
string? Type,
12+
string? TypeText,
13+
string? Description,
14+
bool? Optional,
15+
string? Default,
16+
int? Minimum,
17+
long? Maximum,
18+
string[]? EnumValues);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright Corsinvest Srl
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
6+
namespace Corsinvest.ProxmoxVE.Api.Metadata;
7+
8+
/// <summary>Flat cache child node</summary>
9+
public record FlatChildInfo(
10+
string Name,
11+
bool? Indexed, // null = false (omitted)
12+
bool? HasChildren); // null = false (omitted)
13+
14+
/// <summary>Flat cache resource — keys, children, methods</summary>
15+
public record FlatResourceInfo(
16+
string[]? Keys,
17+
FlatChildInfo[]? Children,
18+
Dictionary<string, FlatMethodInfo>? Methods);

src/Corsinvest.ProxmoxVE.Api.Metadata/GeneratorClassApi.cs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
using Newtonsoft.Json.Linq;
77
using System.Text;
8+
using System.Text.Json;
9+
using System.Text.Json.Serialization;
810

911
namespace Corsinvest.ProxmoxVE.Api.Metadata;
1012

@@ -60,4 +62,109 @@ public static async Task<string> GetJsonSchemaFromApiDocAsync(string host, int p
6062

6163
return json.ToString();
6264
}
65+
66+
private static readonly JsonSerializerOptions FlatWriteOpts = new()
67+
{
68+
WriteIndented = false,
69+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
70+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
71+
};
72+
73+
private static readonly JsonSerializerOptions FlatReadOpts = new()
74+
{
75+
PropertyNameCaseInsensitive = true
76+
};
77+
78+
/// <summary>
79+
/// Build a compact flat JSON cache from a ClassApi tree.
80+
/// </summary>
81+
public static string BuildFlatCache(ClassApi root)
82+
{
83+
var dict = new Dictionary<string, FlatResourceInfo>();
84+
Traverse(root, dict);
85+
return JsonSerializer.Serialize(dict, FlatWriteOpts);
86+
}
87+
88+
/// <summary>
89+
/// Load a flat cache from a JSON string previously built with BuildFlatCache.
90+
/// </summary>
91+
public static Dictionary<string, FlatResourceInfo>? LoadFlatCache(string json)
92+
=> JsonSerializer.Deserialize<Dictionary<string, FlatResourceInfo>>(json, FlatReadOpts);
93+
94+
/// <summary>
95+
/// Rebuild a ClassApi tree from a flat cache dictionary.
96+
/// </summary>
97+
public static ClassApi BuildClassApiFromFlat(Dictionary<string, FlatResourceInfo> flat)
98+
{
99+
var root = new ClassApi();
100+
// Sort by path depth so parents are created before children
101+
foreach (var kv in flat.OrderBy(kv => kv.Key.Count(c => c == '/')))
102+
{
103+
var path = kv.Key;
104+
var info = kv.Value;
105+
var segments = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
106+
var name = segments.Last();
107+
var isIndexed = name.StartsWith("{");
108+
109+
// Find or create parent node
110+
var parentPath = "/" + string.Join("/", segments.Take(segments.Length - 1).ToArray());
111+
var parent = segments.Length == 1
112+
? root
113+
: ClassApi.GetFromResource(root, parentPath) ?? root;
114+
115+
var node = new ClassApi(path, name, isIndexed, parent);
116+
117+
if (info.Methods != null)
118+
{
119+
foreach (var mkv in info.Methods)
120+
{
121+
node.Methods.Add(new MethodApi(mkv.Key, mkv.Value, node));
122+
}
123+
}
124+
}
125+
return root;
126+
}
127+
128+
private static FlatParamInfo ToFlatParam(ParameterApi p) => new(
129+
p.Name,
130+
string.IsNullOrEmpty(p.Type) ? null : p.Type,
131+
string.IsNullOrEmpty(p.TypeText) ? null : p.TypeText,
132+
string.IsNullOrEmpty(p.Description) ? null : p.Description,
133+
p.Optional ? true : null,
134+
string.IsNullOrEmpty(p.Default) ? null : p.Default,
135+
p.Minimum,
136+
p.Maximum,
137+
p.EnumValues.Length > 0 ? p.EnumValues
138+
: string.Equals(p.Type, "boolean", StringComparison.OrdinalIgnoreCase) ? ["0", "1"]
139+
: null);
140+
141+
private static void Traverse(ClassApi node, Dictionary<string, FlatResourceInfo> dict)
142+
{
143+
if (!node.IsRoot)
144+
{
145+
var methods = new Dictionary<string, FlatMethodInfo>();
146+
foreach (var method in node.Methods)
147+
{
148+
var ps = method.Parameters.Where(p => !node.Keys.Contains(p.Name)).ToArray();
149+
var rps = method.ReturnParameters.ToArray();
150+
methods[method.MethodType.ToLower()] = new FlatMethodInfo(
151+
string.IsNullOrEmpty(method.Comment) ? null : method.Comment,
152+
string.IsNullOrEmpty(method.ReturnType) ? null : method.ReturnType,
153+
string.IsNullOrEmpty(method.ReturnLinkHRef) ? null : method.ReturnLinkHRef,
154+
ps.Length > 0 ? ps.Select(ToFlatParam).ToArray() : null,
155+
rps.Length > 0 ? rps.Select(ToFlatParam).ToArray() : null);
156+
}
157+
158+
var children = node.SubClasses.Select(c => new FlatChildInfo(
159+
c.Name,
160+
c.IsIndexed ? true : null,
161+
c.SubClasses.Count > 0 ? true : null)).ToArray();
162+
163+
dict[node.Resource] = new FlatResourceInfo(
164+
node.Keys.Count > 0 ? [.. node.Keys] : null,
165+
children.Length > 0 ? children : null,
166+
methods.Count > 0 ? methods : null);
167+
}
168+
foreach (var sub in node.SubClasses) { Traverse(sub, dict); }
169+
}
63170
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright Corsinvest Srl
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
6+
#if NETSTANDARD2_0
7+
// Required to support C# 9 record types (init-only properties) on netstandard2.0
8+
namespace System.Runtime.CompilerServices
9+
{
10+
internal static class IsExternalInit { }
11+
}
12+
#endif

src/Corsinvest.ProxmoxVE.Api.Metadata/MethodApi.cs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,25 @@ namespace Corsinvest.ProxmoxVE.Api.Metadata;
1212
/// </summary>
1313
public class MethodApi
1414
{
15-
/// <summary>
16-
/// Constructor
17-
/// </summary>
18-
/// <param name="token"></param>
19-
/// <param name="classApi"></param>
15+
/// <summary>Constructor from flat cache</summary>
16+
/// <param name="httpMethod">HTTP method (get/post/put/delete)</param>
17+
/// <param name="flat">Flat cache method info</param>
18+
/// <param name="classApi">Parent ClassApi node</param>
19+
internal MethodApi(string httpMethod, FlatMethodInfo flat, ClassApi classApi)
20+
{
21+
MethodType = httpMethod.ToUpper();
22+
MethodName = httpMethod;
23+
Comment = flat.Comment ?? string.Empty;
24+
ReturnType = flat.ReturnType ?? string.Empty;
25+
ReturnLinkHRef = flat.ReturnLinkHRef ?? string.Empty;
26+
ReturnIsArray = ReturnType == "array";
27+
ReturnIsNull = ReturnType == "null";
28+
ClassApi = classApi;
29+
if (flat.Params != null) { Parameters.AddRange(flat.Params.Select(p => new ParameterApi(p))); }
30+
if (flat.ReturnParams != null) { ReturnParameters.AddRange(flat.ReturnParams.Select(p => new ParameterApi(p))); }
31+
}
32+
33+
/// <summary>Constructor from JSON token</summary>
2034
public MethodApi(JToken token, ClassApi classApi)
2135
{
2236
MethodType = token["method"].ToString();

src/Corsinvest.ProxmoxVE.Api.Metadata/ParameterApi.cs

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,25 @@ namespace Corsinvest.ProxmoxVE.Api.Metadata;
1515
/// </summary>
1616
public class ParameterApi
1717
{
18-
/// <summary>
19-
/// Parameter Api
20-
/// </summary>
21-
/// <param name="token"></param>
18+
/// <summary>Constructor from flat cache</summary>
19+
/// <param name="flat">Flat cache parameter info</param>
20+
internal ParameterApi(FlatParamInfo flat)
21+
{
22+
Name = flat.Name;
23+
NameIndexed = flat.Name.Replace("[n]", string.Empty);
24+
IsIndexed = flat.Name.EndsWith("[n]");
25+
Type = flat.Type ?? string.Empty;
26+
TypeText = flat.TypeText ?? string.Empty;
27+
Description = flat.Description ?? string.Empty;
28+
Optional = flat.Optional ?? false;
29+
Default = flat.Default ?? string.Empty;
30+
Minimum = flat.Minimum.HasValue ? (int?)flat.Minimum.Value : null;
31+
Maximum = flat.Maximum;
32+
EnumValues = flat.EnumValues ?? [];
33+
}
34+
35+
/// <summary>Constructor from JSON token</summary>
36+
/// <param name="token">JSON token representing the parameter</param>
2237
public ParameterApi(JToken token)
2338
{
2439
Name = ((JProperty)token.Parent).Name;
@@ -139,8 +154,17 @@ public object RendererValue(object value)
139154
: string.Empty;
140155
break;
141156

142-
case "timestamp": break;
143-
case "timestamp_gmt": break;
157+
case "timestamp":
158+
value = long.TryParse(value?.ToString(), out var ts) && ts > 0
159+
? DateTimeOffset.FromUnixTimeSeconds(ts).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")
160+
: string.Empty;
161+
break;
162+
163+
case "timestamp_gmt":
164+
value = long.TryParse(value?.ToString(), out var tsGmt) && tsGmt > 0
165+
? DateTimeOffset.FromUnixTimeSeconds(tsGmt).UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss")
166+
: string.Empty;
167+
break;
144168

145169
default:
146170
if (value is ExpandoObject || value is IList)

0 commit comments

Comments
 (0)