-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathgenerate_sdk_csharp.py
962 lines (773 loc) · 36.9 KB
/
generate_sdk_csharp.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
import json
import os
import re
import requests
import yaml
import argparse
def load_openapi_spec(path_or_url):
"""
Load OpenAPI specification from a local file or a URL.
:param path_or_url: Path to the local file or URL of the OpenAPI spec.
:return: The content of the OpenAPI spec.
"""
if path_or_url.startswith('http://') or path_or_url.startswith('https://'):
# It's a URL
response = requests.get(path_or_url)
response.raise_for_status() # Raise an error for bad status codes
return yaml.safe_load(response.text)
elif os.path.isfile(path_or_url):
# It's a local file
with open(path_or_url, 'r') as file:
return yaml.safe_load(file)
else:
raise ValueError("The provided path_or_url is neither a valid URL nor a local file path.")
def generate_api_response_class():
"""Generates the ApiResponse class."""
api_response_code = """
namespace Solcast.Utilities
{
public class ApiResponse<T>(T data, string rawResponse)
{
public T Data { get; set; } = data;
public string RawResponse { get; set; } = rawResponse;
}
}
"""
return api_response_code
def resolve_reference(spec, ref):
"""Resolve $ref in the OpenAPI/Swagger spec."""
parts = ref.lstrip('#/').split('/')
result = spec
for part in parts:
result = result[part]
return result
def get_endpoint_details(spec, endpoint, method):
"""Extract details for a given endpoint and method."""
paths = spec.get('paths', {})
endpoint_info = paths.get(endpoint, {}).get(method, {})
# Handle Swagger 2.0 and OpenAPI 3.0 differences
parameters = []
if 'parameters' in endpoint_info:
parameters = endpoint_info.get('parameters', [])
elif 'requestBody' in endpoint_info:
request_body = endpoint_info.get('requestBody', {})
if request_body and 'content' in request_body:
# Only handles the first content type for simplicity
content = list(request_body['content'].values())[0]
parameters = [{'name': 'body', 'in': 'body', 'schema': content['schema']}]
# Resolve references for parameters
resolved_parameters = []
for param in parameters:
if '$ref' in param:
resolved_param = resolve_reference(spec, param['$ref'])
resolved_parameters.append(resolved_param)
else:
resolved_parameters.append(param)
return resolved_parameters
def map_openapi_type_to_csharp(openapi_type=None, format=None, schema=None, spec=None):
"""Map OpenAPI types to C# types, including body schemas with $ref."""
if schema and spec:
if '$ref' in schema:
resolved_schema = resolve_reference(spec, schema['$ref'])
return resolved_schema.get('title', schema['$ref'].split('/')[-1])
elif 'type' in schema:
openapi_type = schema['type']
format = schema.get('format')
if not openapi_type:
openapi_type = "string"
if openapi_type == "number":
if format == "float":
return "float?"
elif format == "double":
return "double?"
else:
return "double?"
elif openapi_type == "integer":
return "int?"
elif openapi_type == "boolean":
return "bool?"
elif openapi_type == "array":
if schema:
items = schema.get('items', {})
item_type = map_openapi_type_to_csharp(items.get('type'), items.get('format'), items, spec)
return f"List<{item_type}>"
else:
return "List<string>" # Default to List<string> if no schema information is available
elif openapi_type == "object" and schema:
return schema.get('title', "Dictionary<string, object>")
else:
return "string"
def to_pascal_case(string):
"""Convert a string to PascalCase."""
return re.sub(r'(_|-)+', ' ', string).title().replace(' ', '')
def to_camel_case(string):
"""Convert a string to camelCase."""
return string[0].lower() + to_pascal_case(string)[1:]
def generate_csharp_class_with_usings(class_name, methods, required_usings):
"""Generates a C# class that contains multiple methods and the necessary 'using' statements."""
# Combine necessary usings into the header
usings = "\n".join([f"using {u};" for u in order_namespaces(required_usings)])
class_template = f"""
{usings}
namespace Solcast.Clients
{{
public class {class_name} : BaseClient
{{
public {class_name}()
{{
}}
{methods} }}
}}
"""
return class_template
# Base Client Generation
def generate_base_client():
"""Generates the base client class."""
base_client_code = """
using System;
using System.Net.Http;
using System.Reflection;
using System.Net;
using System.Text.RegularExpressions;
namespace Solcast.Clients
{
public abstract class BaseClient : IDisposable
{
private static bool _updateChecked = false;
private bool _disposed = false;
protected readonly HttpClient _httpClient;
protected BaseClient(string baseUrl = null, IWebProxy proxy = null, bool checkForUpdates = true)
{
if (checkForUpdates && !_updateChecked && !IsSdkUpdateCheckSuppressed())
{
CheckForUpdates();
_updateChecked = true;
}
var apiKey = Environment.GetEnvironmentVariable("SOLCAST_API_KEY");
if (string.IsNullOrEmpty(apiKey))
{
throw new MissingApiKeyException("The SOLCAST_API_KEY environment variable is not set.");
}
var httpClientHandler = new HttpClientHandler();
if (proxy != null)
{
httpClientHandler.Proxy = proxy;
httpClientHandler.UseProxy = true;
}
_httpClient = new HttpClient(httpClientHandler)
{
BaseAddress = new Uri(baseUrl ?? SolcastUrls.BaseUrl)
};
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
var version = GetAssemblyVersion();
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd($"solcast-api-csharp-sdk/{version}");
}
private static bool IsSdkUpdateCheckSuppressed()
{
var suppressFlag = Environment.GetEnvironmentVariable("SUPPRESS_SDK_UPDATE_CHECK");
return suppressFlag?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
}
public static void CheckForUpdates()
{
const string githubApiUrl = "https://api.github.com/repos/solcast/solcast-api-csharp-sdk/releases/latest";
try
{
string currentVersion = NormalizeVersion(GetAssemblyVersion());
using var client = new HttpClient();
client.DefaultRequestHeaders.UserAgent.ParseAdd("solcast-sdk-version-check");
var response = client.GetStringAsync(githubApiUrl).Result;
dynamic releaseInfo = Newtonsoft.Json.JsonConvert.DeserializeObject(response);
string latestVersionRaw = releaseInfo?.tag_name ?? "unknown";
string latestVersion = NormalizeVersion(latestVersionRaw);
if (CompareSemanticVersions(currentVersion, latestVersion) < 0)
{
Console.WriteLine($@"A new version of the SDK is available: {latestVersionRaw}.
To update, run the following command:
dotnet add package Solcast --version {latestVersion}
");
}
}
catch (Exception e)
{
// Gracefully handle any errors (e.g., network issues or API rate limits)
Console.WriteLine($"Failed to check for SDK updates: {e.Message}");
}
}
private static bool IsPreReleaseVersion(string version)
{
// Check if the version contains a pre-release identifier
var regex = new Regex(@"-\w+(\.\w+)*$");
return regex.IsMatch(version);
}
private static string NormalizeVersion(string version)
{
return version.TrimStart('v', 'V'); // Remove the "v" prefix (case-insensitive)
}
private static int CompareSemanticVersions(string currentVersion, string latestVersion)
{
var currentParts = ParseSemanticVersion(currentVersion);
var latestParts = ParseSemanticVersion(latestVersion);
// Compare numeric components: major, minor, patch
for (int i = 0; i < 3; i++)
{
int currentPart = currentParts.NumericParts[i];
int latestPart = latestParts.NumericParts[i];
if (currentPart < latestPart) return -1;
if (currentPart > latestPart) return 1;
}
// Compare pre-release components
return ComparePreRelease(currentParts.PreRelease, latestParts.PreRelease);
}
private static (int[] NumericParts, string PreRelease) ParseSemanticVersion(string version)
{
var regex = new Regex(@"^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(-(?<preRelease>[a-zA-Z0-9.-]+))?$");
var match = regex.Match(version);
if (!match.Success)
throw new FormatException($"Invalid semantic version: {version}");
int major = int.Parse(match.Groups["major"].Value);
int minor = int.Parse(match.Groups["minor"].Value);
int patch = int.Parse(match.Groups["patch"].Value);
string preRelease = match.Groups["preRelease"].Value; // May be empty
return (new[] { major, minor, patch }, preRelease);
}
private static int ComparePreRelease(string currentPreRelease, string latestPreRelease)
{
// No pre-release means higher precedence
if (string.IsNullOrEmpty(currentPreRelease) && !string.IsNullOrEmpty(latestPreRelease))
return 1;
if (!string.IsNullOrEmpty(currentPreRelease) && string.IsNullOrEmpty(latestPreRelease))
return -1;
// If both are null or empty, they are equal
if (string.IsNullOrEmpty(currentPreRelease) && string.IsNullOrEmpty(latestPreRelease))
return 0;
// Split pre-release identifiers by dots and compare lexicographically
var currentParts = currentPreRelease.Split('.');
var latestParts = latestPreRelease.Split('.');
for (int i = 0; i < Math.Max(currentParts.Length, latestParts.Length); i++)
{
string currentPart = i < currentParts.Length ? currentParts[i] : "";
string latestPart = i < latestParts.Length ? latestParts[i] : "";
int currentNumeric, latestNumeric;
bool currentIsNumeric = int.TryParse(currentPart, out currentNumeric);
bool latestIsNumeric = int.TryParse(latestPart, out latestNumeric);
if (currentIsNumeric && latestIsNumeric)
{
// Compare as numbers
if (currentNumeric < latestNumeric) return -1;
if (currentNumeric > latestNumeric) return 1;
}
else
{
// Compare as strings
int comparison = string.Compare(currentPart, latestPart, StringComparison.Ordinal);
if (comparison != 0) return comparison;
}
}
return 0; // Versions are equal
}
private static string GetAssemblyVersion()
{
var attribute = (AssemblyInformationalVersionAttribute)Attribute.GetCustomAttribute(
Assembly.GetExecutingAssembly(),
typeof(AssemblyInformationalVersionAttribute)
);
var version = attribute?.InformationalVersion ?? "1.0.0";
return version.Split('+')[0]; // Return the version without build metadata
}
protected void HandleUnauthorizedResponse(HttpResponseMessage response)
{
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new UnauthorizedApiKeyException("The API key provided is invalid or unauthorized.");
}
response.EnsureSuccessStatusCode();
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_httpClient.Dispose();
}
_disposed = true;
}
}
}
public class MissingApiKeyException : Exception
{
public MissingApiKeyException(string message) : base(message) { }
}
public class UnauthorizedApiKeyException : Exception
{
public UnauthorizedApiKeyException(string message) : base(message) { }
}
}
"""
return base_client_code
def order_namespaces(namespaces, app_namespace="Solcast"):
"""
Orders a set of namespaces according to the recommended sequence:
1. System namespaces
2. Third-party namespaces (non-System, non-application specific)
3. Application-specific namespaces
4. Alias and static using directives (handled separately)
Args:
namespaces (set): A set of namespace strings.
Returns:
list: An ordered list of namespaces.
"""
# Separate namespaces into categories
system_namespace = [ns for ns in namespaces if ns == "System"]
system_namespaces = sorted([ns for ns in namespaces if ns.startswith("System.")])
third_party_namespaces = sorted([ns for ns in namespaces if not ns.startswith("System") and not ns.startswith(app_namespace)])
app_specific_namespaces = sorted([ns for ns in namespaces if ns.startswith(app_namespace)])
alias_and_static = sorted([ns for ns in namespaces if "static" in ns or "=" in ns])
# Combine all categories in the recommended order
ordered_namespaces = system_namespace + system_namespaces + \
third_party_namespaces + app_specific_namespaces + alias_and_static
return ordered_namespaces
def generate_csharp_method_with_usings(endpoint, method, parameters, spec, response_type=None, context=""):
"""Generates a C# method for the given endpoint, method, and parameters, and returns necessary 'using' statements."""
required_usings = {"System", "System.Net.Http", "System.Threading.Tasks", "System.Collections.Generic", "System.Linq", "Solcast.Utilities", "Solcast.Models"}
if response_type:
required_usings.update({"Solcast.Utilities"})
# Default response type if not provided
response_type = response_type or "string"
# Include context in the function name if provided
last_segment = endpoint.split('/')[-1]
context_prefix = to_pascal_case(context) if context else ""
function_name = f"{method.capitalize()}{context_prefix}{to_pascal_case(last_segment)}"
# Separate parameters into required, optional, and body
required_params = [param for param in parameters if param.get('required', False) and param['in'] != 'body']
optional_params = [param for param in parameters if not param.get('required', False) and param['in'] != 'body']
body_param = next((param for param in parameters if param['in'] == 'body'), None)
# Generate XML comments for the method
method_description = spec.get('paths', {}).get(endpoint, {}).get(method, {}).get('description', "")
xml_comment = generate_xml_comment(method_description, indent_level=8)
param_comments = ""
for param in parameters:
param_name = to_camel_case(param['name'])
param_description = param.get('description', "")
param_comments += f" /// <param name=\"{param_name}\">{param_description}</param>\n"
# Build method signature with XML comments
method_signature = f"{xml_comment}{param_comments} public async Task<ApiResponse<{response_type}>> {function_name}(\n"
param_list = []
# Add required parameters to signature
for param in required_params:
param_name = to_camel_case(param['name'])
param_type = map_openapi_type_to_csharp(param.get('type'), param.get('format'), param.get('schema'), spec)
param_list.append(f" {param_type} {param_name}")
# Add body parameter to signature if present
if body_param:
body_type = map_openapi_type_to_csharp(None, None, body_param['schema'], spec)
param_list.append(f" {body_type} body")
required_usings.add("Newtonsoft.Json")
# Add optional parameters to signature
for param in optional_params:
param_name = to_camel_case(param['name'])
param_type = map_openapi_type_to_csharp(param.get('type'), param.get('format'), param.get('schema'), spec)
param_list.append(f" {param_type} {param_name} = null")
if param_type == "List<string>":
required_usings.add("System.Collections.Generic")
# Complete the method signature
method_signature += ",\n".join(param_list) + "\n )"
# Start building the method body
method_body = "\n {\n"
method_body += " var parameters = new Dictionary<string, string>();\n"
# Add required parameters to the query string
for param in required_params:
param_name = to_camel_case(param['name'])
method_body += f" parameters.Add(\"{param_name}\", {param_name}.ToString());\n"
# Add optional parameters to the query string
for param in optional_params:
param_name = to_camel_case(param['name'])
param_type = map_openapi_type_to_csharp(param.get('type'), param.get('format'), param.get('schema'), spec)
if param_type in ["double?", "int?", "float?", "bool?"]:
method_body += f" if ({param_name}.HasValue) parameters.Add(\"{param_name}\", {param_name}.Value.ToString());\n"
elif param_type == "List<string>":
method_body += f" if ({param_name} != null && {param_name}.Any()) parameters.Add(\"{param_name}\", string.Join(\",\", {param_name}));\n"
else:
method_body += f" if ({param_name} != null) parameters.Add(\"{param_name}\", {param_name}.ToString());\n"
# Add JSON body parameter handling if present
if body_param:
method_body += """
var jsonContent = JsonConvert.SerializeObject(body);
var requestBody = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json");
"""
# Convert the endpoint to a constant from SolcastUrls
endpoint_constant = to_pascal_case('_'.join(endpoint.strip('/').split('/')[1:]))
# Add request execution and response handling
method_body += """
var queryString = string.Join("&", parameters.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value ?? string.Empty)}"));
var response = await _httpClient.""" + (to_pascal_case(method) + "Async") + f"""(SolcastUrls.{endpoint_constant} + $"?{{queryString}}"{", requestBody" if body_param else ""});
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{{
throw new UnauthorizedApiKeyException("The API key provided is invalid or unauthorized.");
}}
response.EnsureSuccessStatusCode();
var rawContent = await response.Content.ReadAsStringAsync();\n"""
# Add conditional deserialization based on the 'format' parameter
if response_type:
required_usings.add("Newtonsoft.Json")
method_body += """
if (parameters.ContainsKey("format") && parameters["format"] == "json")
{
var data = JsonConvert.DeserializeObject<""" + response_type + """>(rawContent);
return new ApiResponse<""" + response_type + """>(data, rawContent);
}
return new ApiResponse<""" + response_type + """>(null, rawContent);
}
"""
else:
method_body += " return new ApiResponse<string>(null, rawContent);\n }"
return method_signature + method_body, required_usings
def extract_endpoints_from_spec(spec, endpoint_groups):
"""Extracts endpoint URLs and operation IDs or summaries from the OpenAPI spec, filtering by endpoint groups."""
endpoint_details = {group: {} for group in endpoint_groups}
paths = spec.get('paths', {})
for endpoint, methods in paths.items():
# Determine which endpoint group this endpoint belongs to
group = next((group for group in endpoint_groups if endpoint.startswith(group)), None)
if not group:
continue
for method, details in methods.items():
if method == 'parameters':
continue
# Prefer operationId, but fall back to summary if available
operation_id = details.get('operationId', None)
summary = details.get('summary', None)
key = operation_id if operation_id else summary
if key:
# Convert operation ID or summary to a PascalCase constant name
constant_name = generate_constant_name_from_url(endpoint)
endpoint_details[group][constant_name] = endpoint.strip('/')
return endpoint_details
def generate_constant_name_from_url(url):
"""Generates a constant name from the endpoint URL."""
# Remove leading and trailing slashes, split by slashes, and join using PascalCase
parts = url.strip('/').split('/')
return ''.join(to_pascal_case(part) for part in parts[1:])
def generate_solcast_urls_class_from_spec(endpoint_details):
"""Generates the SolcastUrls class with dynamically extracted endpoints from the spec, grouped by endpoint groups."""
base_url = os.getenv("SOLCAST_API_BASE_URL", "https://api.solcast.com.au")
urls_code = '''
namespace Solcast
{
public static class SolcastUrls
{
public static readonly string BaseUrl = "''' + base_url + '''";
'''
for group, endpoints in endpoint_details.items():
# Add a comment for the endpoint group
group_comment = f" // {' '.join(group.strip('/').split('/')[-1].split('_'))} data endpoints"
urls_code += f"\n{group_comment}\n"
for constant_name, endpoint in endpoints.items():
urls_code += f' public static readonly string {constant_name} = "{endpoint}";\n'
urls_code += """
}
}
"""
return urls_code
def generate_xml_comment(description, indent_level=4):
"""Generate an XML comment from a multi-line description string with each line prefixed by ///, aligned properly."""
if description:
# Calculate the indent based on the given indent level
indent = ' ' * indent_level
# Split the description into lines and prefix each with "///"
comment_lines = "\n".join(f"{indent}/// {line}" for line in description.splitlines())
return f"\n{indent}/// <summary>\n{comment_lines}\n{indent}/// </summary>\n"
return "\n"
# Generate Models for Requests and Responses
def generate_csharp_model_class(class_name, properties, required_properties, spec):
"""Generates a C# class for a model (request or response), with appropriate using statements."""
required_usings = {"Newtonsoft.Json"}
class_code_lines = [f" public class {class_name}", "\n {"]
for prop_name, prop_info in properties.items():
description = prop_info.get('description', "")
xml_comment = generate_xml_comment(description, indent_level=8)
# Determine property type
if '$ref' in prop_info:
resolved_schema = resolve_reference(spec, prop_info['$ref'])
prop_type = resolved_schema.get('title', prop_info['$ref'].split('/')[-1])
elif prop_info.get('type') == 'array' and 'items' in prop_info:
items_info = prop_info['items']
if '$ref' in items_info:
resolved_schema = resolve_reference(spec, items_info['$ref'])
nested_class_name = resolved_schema.get('title', items_info['$ref'].split('/')[-1])
prop_type = f"List<{nested_class_name}>"
else:
item_type = map_openapi_type_to_csharp(items_info.get('type'), items_info.get('format'), items_info, spec)
prop_type = f"List<{item_type}>"
required_usings.add("System.Collections.Generic")
elif prop_info.get('type') == 'object':
if prop_info.get('additionalProperties'):
prop_type = "IDictionary<string, object>"
required_usings.add("System.Collections.Generic")
else:
prop_type = "Dictionary<string, object>"
else:
prop_type = map_openapi_type_to_csharp(prop_info.get('type'), prop_info.get('format'), prop_info)
if prop_type.startswith("List<") or "Dictionary<" in prop_type:
required_usings.add("System.Collections.Generic")
prop_required = prop_name in required_properties
prop_code = f"""{xml_comment} [JsonProperty("{prop_name}")]
public {prop_type} {to_pascal_case(prop_name)} {{ get; set; }}{' // Required' if prop_required else ''}"""
class_code_lines.append(prop_code + "\n")
class_code_lines.append(" }") # Closing class brace
class_code = "".join(class_code_lines)
# Wrap the class inside a namespace with extra indentation
namespace_code = f"""namespace Solcast.Models
{{
{class_code}
}}
"""
return namespace_code, required_usings
def generate_models(spec):
"""Generates all request and response models from OpenAPI definitions."""
models = {"requests": [], "responses": []}
all_required_usings = set()
# Handle Swagger 2.0 and OpenAPI 3.0 schema paths
if spec.get('swagger') == '2.0':
definitions = spec.get('definitions', {})
else:
definitions = spec.get('components', {}).get('schemas', {})
for definition_name, definition in definitions.items():
class_code, required_usings = generate_csharp_model_class(
definition_name,
definition.get('properties', {}),
definition.get('required', []),
spec
)
all_required_usings.update(required_usings)
# Classify as request or response based on naming convention (could be improved with additional logic)
if "request" in definition_name.lower():
models["requests"].append((class_code, required_usings))
else:
models["responses"].append((class_code, required_usings))
return models, all_required_usings
def generate_model_class_with_usings(class_code, required_usings):
"""Generates the complete C# class code with necessary using statements."""
usings = "\n".join([f"using {u};" for u in order_namespaces(required_usings)])
return f"{usings}\n\n{class_code}"
def save_model_class_to_file(class_code, required_usings, folder_path, file_name):
"""Save the generated class with usings to a file, creating the folder structure if necessary."""
os.makedirs(folder_path, exist_ok=True)
complete_class_code = generate_model_class_with_usings(class_code, required_usings)
file_path = os.path.join(folder_path, file_name)
with open(file_path, 'w') as file:
file.write(complete_class_code)
print(f"Saved generated code for {file_name} to {file_path}")
def get_response_type(spec, endpoint, method):
"""Extract the response type from the OpenAPI spec."""
paths = spec.get('paths', {})
endpoint_info = paths.get(endpoint, {}).get(method, {})
responses = endpoint_info.get('responses', {})
# Handle Swagger 2.0 and OpenAPI 3.0 response schemas
response_schema = None
if '200' in responses:
response = responses['200']
if spec.get('swagger') == '2.0':
response_schema = response.get('schema', {})
else:
response_content = response.get('content', {})
response_schema = list(response_content.values())[0].get('schema', {}) if response_content else {}
if response_schema and '$ref' in response_schema:
return response_schema['$ref'].split('/')[-1]
return None
def generate_client_class_with_methods(spec, endpoint_group):
"""Generates a client class for all endpoints in a group (e.g., /data/historic/)."""
# Extract the class name from the endpoint group
class_name = f"{to_pascal_case(endpoint_group.strip('/').split('/')[-1])}Client"
methods = []
all_required_usings = set()
paths = spec.get('paths', {})
for endpoint, path_info in paths.items():
if not endpoint.startswith(endpoint_group):
continue
for http_method, method_info in path_info.items():
if http_method not in ['get', 'post', 'put', 'delete', 'patch']:
continue
response_type = get_response_type(spec, endpoint, http_method)
parameters = get_endpoint_details(spec, endpoint, http_method)
method_code, required_usings = generate_csharp_method_with_usings(endpoint, http_method, parameters, spec, response_type)
methods.append(method_code)
all_required_usings.update(required_usings)
# Combine all methods into a single string, correctly formatted
methods_code = "".join(f"{method}" for method in methods)
# Generate the complete class code with usings and methods
class_code = generate_csharp_class_with_usings(class_name, methods_code, all_required_usings)
return class_code
def load_unmetered_locations(file_path):
"""Load unmetered locations from a JSON file."""
with open(file_path, 'r') as file:
return json.load(file)
def generate_unmetered_locations_class(locations):
"""Generates the UnmeteredLocations.cs file content."""
# Base namespace and class definition
class_code = """
using System.Collections.Generic;
namespace Solcast
{
public static class UnmeteredLocations
{
public static readonly Dictionary<string, Location> Locations = new Dictionary<string, Location>
{
"""
# Add each location as a dictionary entry
for location in locations:
location_code = f""" {{
"{location['name']}", new Location
{{
Latitude = {location['latitude']},
Longitude = {location['longitude']},
ResourceId = "{location['resourceId']}"
}}
}},
"""
class_code += location_code
# Close the dictionary and class definition
class_code += """
};
public static List<string> LoadTestLocationNames()
{
return new List<string>(Locations.Keys);
}
public static (List<double> Latitudes, List<double> Longitudes) LoadTestLocationCoordinates()
{
var latitudes = new List<double>();
var longitudes = new List<double>();
foreach (var location in Locations.Values)
{
latitudes.Add(location.Latitude);
longitudes.Add(location.Longitude);
}
return (latitudes, longitudes);
}
}
public class Location
{
public double Latitude { get; set; }
public double Longitude { get; set; }
public string ResourceId { get; set; }
}
}
"""
return class_code
def save_class_to_file(class_code, folder_path, file_name):
"""Save the generated class to a file, creating the folder structure if necessary."""
os.makedirs(folder_path, exist_ok=True)
file_path = os.path.join(folder_path, file_name)
with open(file_path, 'w') as file:
file.write(class_code)
print(f"Saved generated code for {file_name} to {file_path}")
def get_class_name_from_endpoint_group(endpoint_group):
"""Infer the class name based on the endpoint group."""
return to_pascal_case(endpoint_group.strip("/").split("/")[-1]) + "Client"
def generate_aggregation_client_class(spec, endpoint_suffix='/aggregations'):
"""Generates a client class for all endpoints ending with '/aggregations'."""
class_name = "AggregationClient"
methods = []
all_required_usings = set()
paths = spec.get('paths', {})
for endpoint, path_info in paths.items():
if not endpoint.endswith(endpoint_suffix):
continue
# Determine the context (e.g., live, forecast) from the endpoint
context = ''
if 'live' in endpoint:
context = 'live'
elif 'forecast' in endpoint:
context = 'forecast'
for http_method, method_info in path_info.items():
if http_method not in ['get', 'post', 'put', 'delete', 'patch']:
continue
response_type = get_response_type(spec, endpoint, http_method)
parameters = get_endpoint_details(spec, endpoint, http_method)
method_code, required_usings = generate_csharp_method_with_usings(endpoint, http_method, parameters, spec, response_type, context)
methods.append(method_code)
all_required_usings.update(required_usings)
# Combine all methods into a single string, correctly formatted
methods_code = "".join(f" {method}" for method in methods)
# Generate the complete class code with usings and methods
class_code = generate_csharp_class_with_usings(class_name, methods_code, all_required_usings)
# include using Solcast.Models
return class_code
def extract_class_name(class_code):
"""Extract the class name from the C# class definition."""
match = re.search(r"\bclass\s+(\w+)", class_code)
if match:
return match.group(1)
raise ValueError("Class name could not be determined from the class code.")
if __name__ == "__main__":
# Setup argument parser
parser = argparse.ArgumentParser(description="Generate SDK classes from Solcast OpenAPI spec.")
# Add command-line arguments
parser.add_argument(
'--dev',
action='store_true',
help="Use the development OpenAPI spec (https://dev-api.solcast.com.au/openapi/v1/openapi.json)"
)
parser.add_argument(
'--path',
type=str,
help="Specify the path or URL to the OpenAPI spec"
)
# Parse arguments
args = parser.parse_args()
# Default OpenAPI spec path (production)
openapi_spec_path = 'https://api.solcast.com.au/openapi/v1/openapi.json'
# Override if --dev is specified
if args.dev:
print("Using development OpenAPI spec...")
openapi_spec_path = 'https://dev-api.solcast.com.au/openapi/v1/openapi.json'
# Override if --path is provided
if args.path:
print(f"Using custom OpenAPI spec path: {args.path}")
openapi_spec_path = args.path
# Default unmetered locations file path
json_file_path = 'unmetered_locations.json'
# Load the OpenAPI spec and unmetered locations
spec = load_openapi_spec(openapi_spec_path)
locations = load_unmetered_locations(json_file_path)
# Define the endpoint groups and their corresponding client class names
endpoint_groups = [
'/data/live/',
'/data/forecast/',
'/data/historic/',
'/data/tmy/',
'/resources/pv_power_site',
]
# Generate and save UnmeteredLocations class
unmetered_locations_code = generate_unmetered_locations_class(locations)
save_class_to_file(unmetered_locations_code, os.path.join('src', 'Solcast', 'Utilities'), 'UnmeteredLocations.cs')
# Extract endpoint details for SolcastUrls
endpoint_details = extract_endpoints_from_spec(spec, endpoint_groups)
# Generate and save SolcastUrls class dynamically from spec
solcast_urls_code = generate_solcast_urls_class_from_spec(endpoint_details)
save_class_to_file(solcast_urls_code, os.path.join('src', 'Solcast', 'Utilities'), 'SolcastUrls.cs')
# Generate and save base client
base_client_code = generate_base_client()
save_class_to_file(base_client_code, os.path.join('src', 'Solcast', 'Clients'), 'BaseClient.cs')
# Generate and save the ApiResponse class
api_response_code = generate_api_response_class()
save_class_to_file(api_response_code, os.path.join('src', 'Solcast', 'Utilities'), 'ApiResponse.cs')
# Generate and save specific clients
for endpoint_group in endpoint_groups:
client_class_code = generate_client_class_with_methods(spec, endpoint_group)
save_class_to_file(client_class_code, os.path.join('src', 'Solcast', 'Clients'), f'{get_class_name_from_endpoint_group(endpoint_group)}.cs')
# Generate and save models
models, all_required_usings = generate_models(spec)
for model_type, classes in models.items():
for class_code, required_usings in classes:
class_name = extract_class_name(class_code)
save_model_class_to_file(class_code, required_usings, os.path.join('src', 'Solcast', 'Models', to_pascal_case(model_type)), f"{class_name}.cs")
# Generate and save AggregationClient class
aggregation_client_code = generate_aggregation_client_class(spec)
save_class_to_file(aggregation_client_code, os.path.join('src', 'Solcast', 'Clients'), 'AggregationClient.cs')
print(f"Code generation complete for {openapi_spec_path}")