jararaca 0.3.11a15__py3-none-any.whl → 0.3.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of jararaca might be problematic. Click here for more details.

@@ -8,7 +8,7 @@ from datetime import date, datetime, time
8
8
  from decimal import Decimal
9
9
  from enum import Enum
10
10
  from io import StringIO
11
- from types import NoneType, UnionType
11
+ from types import FunctionType, NoneType, UnionType
12
12
  from typing import (
13
13
  IO,
14
14
  Annotated,
@@ -24,10 +24,10 @@ from typing import (
24
24
  from uuid import UUID
25
25
 
26
26
  from fastapi import Request, Response, UploadFile
27
- from fastapi.params import Body, Cookie, Depends, Header, Path, Query
27
+ from fastapi.params import Body, Cookie, Depends, Form, Header, Path, Query
28
28
  from fastapi.security.http import HTTPBase
29
29
  from pydantic import BaseModel, PlainValidator
30
- from pydantic_core import PydanticUndefinedType
30
+ from pydantic_core import PydanticUndefined
31
31
 
32
32
  from jararaca.microservice import Microservice
33
33
  from jararaca.presentation.decorators import HttpMapping, RestController
@@ -35,6 +35,11 @@ from jararaca.presentation.websocket.decorators import RegisterWebSocketMessage
35
35
  from jararaca.presentation.websocket.websocket_interceptor import (
36
36
  WebSocketMessageWrapper,
37
37
  )
38
+ from jararaca.tools.typescript.decorators import (
39
+ MutationEndpoint,
40
+ QueryEndpoint,
41
+ SplitInputOutput,
42
+ )
38
43
 
39
44
  CONSTANT_PATTERN = re.compile(r"^[A-Z_]+$")
40
45
 
@@ -43,6 +48,206 @@ def is_constant(name: str) -> bool:
43
48
  return CONSTANT_PATTERN.match(name) is not None
44
49
 
45
50
 
51
+ def unwrap_annotated_type(field_type: Any) -> tuple[Any, list[Any]]:
52
+ """
53
+ Recursively unwrap Annotated types to find the real underlying type.
54
+
55
+ Args:
56
+ field_type: The type to unwrap, which may be deeply nested Annotated types
57
+
58
+ Returns:
59
+ A tuple of (unwrapped_type, all_metadata) where:
60
+ - unwrapped_type is the final non-Annotated type
61
+ - all_metadata is a list of all metadata from all Annotated layers
62
+ """
63
+ all_metadata = []
64
+ current_type = field_type
65
+
66
+ while get_origin(current_type) == Annotated:
67
+ # Collect metadata from current layer
68
+ if hasattr(current_type, "__metadata__"):
69
+ all_metadata.extend(current_type.__metadata__)
70
+
71
+ # Move to the next inner type
72
+ if hasattr(current_type, "__args__") and len(current_type.__args__) > 0:
73
+ current_type = current_type.__args__[0]
74
+ else:
75
+ break
76
+
77
+ return current_type, all_metadata
78
+
79
+
80
+ def is_upload_file_type(field_type: Any) -> bool:
81
+ """
82
+ Check if a type is UploadFile or a list/array of UploadFile.
83
+
84
+ Args:
85
+ field_type: The type to check
86
+
87
+ Returns:
88
+ True if it's UploadFile or list[UploadFile], False otherwise
89
+ """
90
+ if field_type == UploadFile:
91
+ return True
92
+
93
+ # Check for list[UploadFile], List[UploadFile], etc.
94
+ origin = get_origin(field_type)
95
+ if origin in (list, frozenset, set):
96
+ args = getattr(field_type, "__args__", ())
97
+ if args and args[0] == UploadFile:
98
+ return True
99
+
100
+ return False
101
+
102
+
103
+ def should_exclude_field(
104
+ field_name: str, field_type: Any, basemodel_type: Type[Any]
105
+ ) -> bool:
106
+ """
107
+ Check if a field should be excluded from TypeScript interface generation.
108
+
109
+ Args:
110
+ field_name: The name of the field
111
+ field_type: The type annotation of the field
112
+ basemodel_type: The BaseModel class containing the field
113
+
114
+ Returns:
115
+ True if the field should be excluded, False otherwise
116
+ """
117
+ # Check if field is private (starts with underscore)
118
+ if field_name.startswith("_"):
119
+ return True
120
+
121
+ # Check if field has Pydantic Field annotation and is excluded via model_fields
122
+ if (
123
+ hasattr(basemodel_type, "model_fields")
124
+ and field_name in basemodel_type.model_fields
125
+ ):
126
+ field_info = basemodel_type.model_fields[field_name]
127
+
128
+ # Check if field is excluded via Field(exclude=True)
129
+ if hasattr(field_info, "exclude") and field_info.exclude:
130
+ return True
131
+
132
+ # Check if field is marked as private via Field(..., alias=None) pattern
133
+ if (
134
+ hasattr(field_info, "alias")
135
+ and field_info.alias is None
136
+ and field_name.startswith("_")
137
+ ):
138
+ return True
139
+
140
+ # Check for Annotated types with Field metadata
141
+ if get_origin(field_type) == Annotated:
142
+ unwrapped_type, all_metadata = unwrap_annotated_type(field_type)
143
+ for metadata in all_metadata:
144
+ # Check if this is a Pydantic Field by looking for expected attributes
145
+ if hasattr(metadata, "exclude") or hasattr(metadata, "alias"):
146
+ # Check if Field has exclude=True
147
+ if hasattr(metadata, "exclude") and metadata.exclude:
148
+ return True
149
+ # Check for private fields with alias=None
150
+ if (
151
+ hasattr(metadata, "alias")
152
+ and metadata.alias is None
153
+ and field_name.startswith("_")
154
+ ):
155
+ return True
156
+
157
+ # Check for Field instances assigned as default values
158
+ # This handles cases like: field_name: str = Field(exclude=True)
159
+ if (
160
+ hasattr(basemodel_type, "__annotations__")
161
+ and field_name in basemodel_type.__annotations__
162
+ ):
163
+ # Check if there's a default value that's a Field instance
164
+ if hasattr(basemodel_type, field_name):
165
+ default_value = getattr(basemodel_type, field_name, None)
166
+ # Check if default value has Field-like attributes (duck typing approach)
167
+ if default_value is not None and hasattr(default_value, "exclude"):
168
+ if getattr(default_value, "exclude", False):
169
+ return True
170
+ # Check for private fields with alias=None in default Field
171
+ if (
172
+ default_value is not None
173
+ and hasattr(default_value, "alias")
174
+ and getattr(default_value, "alias", None) is None
175
+ and field_name.startswith("_")
176
+ ):
177
+ return True
178
+
179
+ return False
180
+
181
+
182
+ def has_default_value(
183
+ field_name: str, field_type: Any, basemodel_type: Type[Any]
184
+ ) -> bool:
185
+ """
186
+ Check if a field has a default value (making it optional in TypeScript).
187
+
188
+ Args:
189
+ field_name: The name of the field
190
+ field_type: The type annotation of the field
191
+ basemodel_type: The BaseModel class containing the field
192
+
193
+ Returns:
194
+ True if the field has a default value, False otherwise
195
+ """
196
+ # Skip literal types as they don't have defaults in the traditional sense
197
+ if get_origin(field_type) is Literal:
198
+ return False
199
+
200
+ # Check if field has default in model_fields (standard Pydantic way)
201
+ if (
202
+ hasattr(basemodel_type, "model_fields")
203
+ and field_name in basemodel_type.model_fields
204
+ ):
205
+ field_info = basemodel_type.model_fields[field_name]
206
+ if field_info.default is not PydanticUndefined:
207
+ return True
208
+
209
+ # Check for Field instances assigned as default values
210
+ # This handles cases like: field_name: str = Field(default="value")
211
+ if (
212
+ hasattr(basemodel_type, "__annotations__")
213
+ and field_name in basemodel_type.__annotations__
214
+ ):
215
+ if hasattr(basemodel_type, field_name):
216
+ default_value = getattr(basemodel_type, field_name, None)
217
+ # Check if it's a Field instance with a default
218
+ if default_value is not None and hasattr(default_value, "default"):
219
+ # Check if the Field has a default value set
220
+ field_default = getattr(default_value, "default", PydanticUndefined)
221
+ if field_default is not PydanticUndefined:
222
+ return True
223
+
224
+ # Check for non-Field default values assigned directly to class attributes
225
+ # This handles cases like: field_name: str = "default_value"
226
+ if hasattr(basemodel_type, field_name):
227
+ default_value = getattr(basemodel_type, field_name, None)
228
+ # If it's not a Field instance but has a value, it's a default
229
+ if (
230
+ default_value is not None
231
+ and not hasattr(default_value, "exclude") # Not a Field instance
232
+ and not hasattr(default_value, "alias")
233
+ ): # Not a Field instance
234
+ return True
235
+
236
+ # Check for Annotated types with Field metadata that have defaults
237
+ if get_origin(field_type) == Annotated:
238
+ unwrapped_type, all_metadata = unwrap_annotated_type(field_type)
239
+ for metadata in all_metadata:
240
+ # Check if this is a Pydantic Field with a default
241
+ if hasattr(metadata, "default") and hasattr(
242
+ metadata, "exclude"
243
+ ): # Ensure it's a Field
244
+ field_default = getattr(metadata, "default", PydanticUndefined)
245
+ if field_default is not PydanticUndefined:
246
+ return True
247
+
248
+ return False
249
+
250
+
46
251
  class ParseContext:
47
252
  def __init__(self) -> None:
48
253
  self.mapped_types: set[Any] = set()
@@ -67,6 +272,13 @@ def snake_to_camel(snake_str: str) -> str:
67
272
  return components[0] + "".join(x.title() for x in components[1:])
68
273
 
69
274
 
275
+ def pascal_to_camel(pascal_str: str) -> str:
276
+ """Convert a PascalCase string to camelCase."""
277
+ if not pascal_str:
278
+ return pascal_str
279
+ return pascal_str[0].lower() + pascal_str[1:]
280
+
281
+
70
282
  def parse_literal_value(value: Any) -> str:
71
283
  if value is None:
72
284
  return "null"
@@ -94,7 +306,14 @@ def parse_literal_value(value: Any) -> str:
94
306
  return "unknown"
95
307
 
96
308
 
97
- def get_field_type_for_ts(field_type: Any) -> Any:
309
+ def get_field_type_for_ts(field_type: Any, context_suffix: str = "") -> Any:
310
+ """
311
+ Convert a Python type to its TypeScript equivalent.
312
+
313
+ Args:
314
+ field_type: The Python type to convert
315
+ context_suffix: Suffix for split models (e.g., "Input", "Output")
316
+ """
98
317
  if field_type is Response:
99
318
  return "unknown"
100
319
  if field_type is Any:
@@ -122,17 +341,20 @@ def get_field_type_for_ts(field_type: Any) -> Any:
122
341
  if field_type == Decimal:
123
342
  return "number"
124
343
  if get_origin(field_type) == ClassVar:
125
- return get_field_type_for_ts(field_type.__args__[0])
344
+ return get_field_type_for_ts(field_type.__args__[0], context_suffix)
126
345
  if get_origin(field_type) == tuple:
127
- return f"[{', '.join([get_field_type_for_ts(field) for field in field_type.__args__])}]"
346
+ return f"[{', '.join([get_field_type_for_ts(field, context_suffix) for field in field_type.__args__])}]"
128
347
  if get_origin(field_type) == list or get_origin(field_type) == frozenset:
129
- return f"Array<{get_field_type_for_ts(field_type.__args__[0])}>"
348
+ return f"Array<{get_field_type_for_ts(field_type.__args__[0], context_suffix)}>"
130
349
  if get_origin(field_type) == set:
131
- return f"Array<{get_field_type_for_ts(field_type.__args__[0])}> // Set"
350
+ return f"Array<{get_field_type_for_ts(field_type.__args__[0], context_suffix)}> // Set"
132
351
  if get_origin(field_type) == dict:
133
- return f"{{[key: {get_field_type_for_ts(field_type.__args__[0])}]: {get_field_type_for_ts(field_type.__args__[1])}}}"
352
+ return f"{{[key: {get_field_type_for_ts(field_type.__args__[0], context_suffix)}]: {get_field_type_for_ts(field_type.__args__[1], context_suffix)}}}"
134
353
  if inspect.isclass(field_type):
135
354
  if not hasattr(field_type, "__pydantic_generic_metadata__"):
355
+ # Check if this is a split model and use appropriate suffix
356
+ if SplitInputOutput.is_split_model(field_type) and context_suffix:
357
+ return f"{field_type.__name__}{context_suffix}"
136
358
  return field_type.__name__
137
359
  pydantic_metadata = getattr(field_type, "__pydantic_generic_metadata__")
138
360
 
@@ -141,12 +363,17 @@ def get_field_type_for_ts(field_type: Any) -> Any:
141
363
  if pydantic_metadata.get("origin") is not None
142
364
  else field_type.__name__
143
365
  )
366
+
367
+ # Check if this is a split model and use appropriate suffix
368
+ if SplitInputOutput.is_split_model(field_type) and context_suffix:
369
+ name = f"{field_type.__name__}{context_suffix}"
370
+
144
371
  args = pydantic_metadata.get("args")
145
372
 
146
373
  if len(args) > 0:
147
374
  return "%s<%s>" % (
148
375
  name,
149
- ", ".join([get_field_type_for_ts(arg) for arg in args]),
376
+ ", ".join([get_field_type_for_ts(arg, context_suffix) for arg in args]),
150
377
  )
151
378
 
152
379
  return name
@@ -156,16 +383,22 @@ def get_field_type_for_ts(field_type: Any) -> Any:
156
383
  if get_origin(field_type) == Literal:
157
384
  return " | ".join([parse_literal_value(x) for x in field_type.__args__])
158
385
  if get_origin(field_type) == UnionType or get_origin(field_type) == typing.Union:
159
- return " | ".join([get_field_type_for_ts(x) for x in field_type.__args__])
386
+ return " | ".join(
387
+ [get_field_type_for_ts(x, context_suffix) for x in field_type.__args__]
388
+ )
160
389
  if (get_origin(field_type) == Annotated) and (len(field_type.__args__) > 0):
390
+ unwrapped_type, all_metadata = unwrap_annotated_type(field_type)
391
+
161
392
  if (
162
393
  plain_validator := next(
163
- (x for x in field_type.__metadata__ if isinstance(x, PlainValidator)),
394
+ (x for x in all_metadata if isinstance(x, PlainValidator)),
164
395
  None,
165
396
  )
166
397
  ) is not None:
167
- return get_field_type_for_ts(plain_validator.json_schema_input_type)
168
- return get_field_type_for_ts(field_type.__args__[0])
398
+ return get_field_type_for_ts(
399
+ plain_validator.json_schema_input_type, context_suffix
400
+ )
401
+ return get_field_type_for_ts(unwrapped_type, context_suffix)
169
402
  return "unknown"
170
403
 
171
404
 
@@ -180,6 +413,59 @@ def get_generic_args(field_type: Any) -> Any:
180
413
  def parse_type_to_typescript_interface(
181
414
  basemodel_type: Type[Any],
182
415
  ) -> tuple[set[type], str]:
416
+ """
417
+ Parse a Pydantic model into TypeScript interface(s).
418
+
419
+ If the model is decorated with @SplitInputOutput, it generates both Input and Output interfaces.
420
+ Otherwise, it generates a single interface.
421
+ """
422
+ # Check if this model should be split into Input/Output interfaces
423
+ if SplitInputOutput.is_split_model(basemodel_type):
424
+ return parse_split_input_output_interfaces(basemodel_type)
425
+
426
+ return parse_single_typescript_interface(basemodel_type)
427
+
428
+
429
+ def parse_split_input_output_interfaces(
430
+ basemodel_type: Type[Any],
431
+ ) -> tuple[set[type], str]:
432
+ """
433
+ Generate both Input and Output TypeScript interfaces for a split model.
434
+ """
435
+ mapped_types: set[type] = set()
436
+ combined_output = StringIO()
437
+
438
+ # Generate Input interface (with optional fields)
439
+ input_mapped_types, input_interface = parse_single_typescript_interface(
440
+ basemodel_type, interface_suffix="Input", force_optional_defaults=True
441
+ )
442
+ mapped_types.update(input_mapped_types)
443
+ combined_output.write(input_interface)
444
+
445
+ # Generate Output interface (all fields required as they come from the backend)
446
+ output_mapped_types, output_interface = parse_single_typescript_interface(
447
+ basemodel_type, interface_suffix="Output", force_optional_defaults=False
448
+ )
449
+ mapped_types.update(output_mapped_types)
450
+ combined_output.write(output_interface)
451
+
452
+ return mapped_types, combined_output.getvalue()
453
+
454
+
455
+ def parse_single_typescript_interface(
456
+ basemodel_type: Type[Any],
457
+ interface_suffix: str = "",
458
+ force_optional_defaults: bool | None = None,
459
+ ) -> tuple[set[type], str]:
460
+ """
461
+ Generate a single TypeScript interface for a Pydantic model.
462
+
463
+ Args:
464
+ basemodel_type: The Pydantic model class
465
+ interface_suffix: Suffix to add to the interface name (e.g., "Input", "Output")
466
+ force_optional_defaults: If True, fields with defaults are optional. If False, all fields are required.
467
+ If None, uses the default behavior (fields with defaults are optional).
468
+ """
183
469
  string_builder = StringIO()
184
470
  mapped_types: set[type] = set()
185
471
 
@@ -206,7 +492,7 @@ def parse_type_to_typescript_interface(
206
492
  enum_values = sorted([(x._name_, x.value) for x in basemodel_type])
207
493
  return (
208
494
  set(),
209
- f"export enum {basemodel_type.__name__} {{\n"
495
+ f"export enum {basemodel_type.__name__}{interface_suffix} {{\n"
210
496
  + "\n ".join([f'\t{name} = "{value}",' for name, value in enum_values])
211
497
  + "\n}\n",
212
498
  )
@@ -230,46 +516,46 @@ def parse_type_to_typescript_interface(
230
516
  for inherited_class in valid_inherited_classes
231
517
  }
232
518
 
233
- extends_expression = (
234
- " extends %s"
235
- % ", ".join(
236
- sorted(
237
- [
238
- (
239
- "%s" % get_field_type_for_ts(inherited_class)
240
- if not inherited_classes_consts_conflict[inherited_class]
241
- else "Omit<%s, %s>"
242
- % (
243
- get_field_type_for_ts(inherited_class),
244
- " | ".join(
245
- sorted(
246
- [
247
- '"%s"' % field_name
248
- for field_name in inherited_classes_consts_conflict[
249
- inherited_class
250
- ]
251
- ]
252
- )
253
- ),
519
+ # Modify inheritance for split interfaces
520
+ extends_expression = ""
521
+ if len(valid_inherited_classes) > 0:
522
+ extends_base_names = []
523
+ for inherited_class in valid_inherited_classes:
524
+ base_name = get_field_type_for_ts(inherited_class, interface_suffix)
525
+ # If the inherited class is also a split model, use the appropriate suffix
526
+ if SplitInputOutput.is_split_model(inherited_class) and interface_suffix:
527
+ base_name = f"{inherited_class.__name__}{interface_suffix}"
528
+
529
+ if inherited_classes_consts_conflict[inherited_class]:
530
+ base_name = "Omit<%s, %s>" % (
531
+ base_name,
532
+ " | ".join(
533
+ sorted(
534
+ [
535
+ '"%s"' % field_name
536
+ for field_name in inherited_classes_consts_conflict[
537
+ inherited_class
538
+ ]
539
+ ]
254
540
  )
255
- )
256
- for inherited_class in valid_inherited_classes
257
- ],
258
- key=lambda x: str(x),
259
- )
541
+ ),
542
+ )
543
+ extends_base_names.append(base_name)
544
+
545
+ extends_expression = " extends %s" % ", ".join(
546
+ sorted(extends_base_names, key=lambda x: str(x))
260
547
  )
261
- if len(valid_inherited_classes) > 0
262
- else ""
263
- )
548
+
549
+ interface_name = f"{basemodel_type.__name__}{interface_suffix}"
264
550
 
265
551
  if is_generic_type(basemodel_type):
266
552
  generic_args = get_generic_args(basemodel_type)
267
553
  string_builder.write(
268
- f"export interface {basemodel_type.__name__}<{', '.join(sorted([arg.__name__ for arg in generic_args]))}>{extends_expression} {{\n"
554
+ f"export interface {interface_name}<{', '.join(sorted([arg.__name__ for arg in generic_args]))}>{extends_expression} {{\n"
269
555
  )
270
556
  else:
271
557
  string_builder.write(
272
- f"export interface {basemodel_type.__name__}{extends_expression} {{\n"
558
+ f"export interface {interface_name}{extends_expression} {{\n"
273
559
  )
274
560
 
275
561
  if hasattr(basemodel_type, "__annotations__"):
@@ -282,16 +568,23 @@ def parse_type_to_typescript_interface(
282
568
  if field_name in cls_consts:
283
569
  continue
284
570
 
285
- has_default_value = (
286
- get_origin(field) is not Literal
287
- and field_name in basemodel_type.model_fields
288
- and not isinstance(
289
- basemodel_type.model_fields[field_name].default,
290
- PydanticUndefinedType,
291
- )
292
- )
571
+ # Check if field should be excluded (private or excluded via Field)
572
+ if should_exclude_field(field_name, field, basemodel_type):
573
+ continue
574
+
575
+ # Determine if field is optional based on the force_optional_defaults parameter
576
+ if force_optional_defaults is True:
577
+ # Input interface: fields with defaults are optional
578
+ is_optional = has_default_value(field_name, field, basemodel_type)
579
+ elif force_optional_defaults is False:
580
+ # Output interface: all fields are required (backend provides complete data)
581
+ is_optional = False
582
+ else:
583
+ # Default behavior: fields with defaults are optional
584
+ is_optional = has_default_value(field_name, field, basemodel_type)
585
+
293
586
  string_builder.write(
294
- f" {snake_to_camel(field_name) if not is_constant(field_name) else field_name}{'?' if has_default_value else ''}: {get_field_type_for_ts(field)};\n"
587
+ f" {snake_to_camel(field_name) if not is_constant(field_name) else field_name}{'?' if is_optional else ''}: {get_field_type_for_ts(field, interface_suffix)};\n"
295
588
  )
296
589
  mapped_types.update(extract_all_envolved_types(field))
297
590
  mapped_types.add(field)
@@ -318,7 +611,7 @@ def parse_type_to_typescript_interface(
318
611
  return_type = NoneType
319
612
 
320
613
  string_builder.write(
321
- f" {snake_to_camel(field_name)}: {get_field_type_for_ts(return_type)};\n"
614
+ f" {snake_to_camel(field_name)}: {get_field_type_for_ts(return_type, interface_suffix)};\n"
322
615
  )
323
616
  mapped_types.update(extract_all_envolved_types(return_type))
324
617
  mapped_types.add(return_type)
@@ -354,12 +647,17 @@ def write_microservice_to_typescript_interface(
354
647
  if rest_controller is None:
355
648
  continue
356
649
 
357
- controller_class_str, types = write_rest_controller_to_typescript_interface(
358
- rest_controller, controller
650
+ controller_class_strio, types, hooks_strio = (
651
+ write_rest_controller_to_typescript_interface(
652
+ rest_controller,
653
+ controller,
654
+ )
359
655
  )
360
656
 
361
657
  mapped_types_set.update(types)
362
- rest_controller_buffer.write(controller_class_str)
658
+ rest_controller_buffer.write(controller_class_strio.getvalue())
659
+ if hooks_strio is not None:
660
+ rest_controller_buffer.write(hooks_strio.getvalue())
363
661
 
364
662
  registered = RegisterWebSocketMessage.get(controller)
365
663
 
@@ -376,6 +674,42 @@ def write_microservice_to_typescript_interface(
376
674
 
377
675
  final_buffer.write(
378
676
  """
677
+ /* eslint-disable */
678
+
679
+ // @ts-nocheck
680
+
681
+ // noinspection JSUnusedGlobalSymbols
682
+
683
+ import { HttpService, HttpBackend, HttpBackendRequest, ResponseType, createClassQueryHooks , createClassMutationHooks, createClassInfiniteQueryHooks, paginationModelByFirstArgPaginationFilter } from "@jararaca/core";
684
+
685
+ function makeFormData(data: Record<string, any>): FormData {
686
+ const formData = new FormData();
687
+ for (const key in data) {
688
+ const value = data[key];
689
+ for (const v of genFormDataValue(value)) {
690
+ formData.append(key, v);
691
+ }
692
+ }
693
+ return formData;
694
+ }
695
+
696
+ function* genFormDataValue(value: any): any {
697
+ if (Array.isArray(value)) {
698
+ // Stringify arrays as JSON
699
+ for (const item of value) {
700
+ // formData.append(`${key}`, item);
701
+ yield* genFormDataValue(item);
702
+ }
703
+ } else if (typeof value === "object" && value.constructor === Object) {
704
+ // Stringify plain objects as JSON
705
+ // formData.append(key, JSON.stringify(value));
706
+ yield JSON.stringify(value);
707
+ } else {
708
+ // For primitives (string, number, boolean), append as-is
709
+ yield value;
710
+ }
711
+ }
712
+
379
713
  export type WebSocketMessageMap = {
380
714
  %s
381
715
  }
@@ -391,36 +725,6 @@ export type WebSocketMessageMap = {
391
725
  )
392
726
  )
393
727
 
394
- final_buffer.write(
395
- """
396
- export type ResponseType =
397
- | "arraybuffer"
398
- | "blob"
399
- | "document"
400
- | "json"
401
- | "text"
402
- | "stream"
403
- | "formdata";
404
-
405
-
406
- export interface HttpBackendRequest {
407
- method: string;
408
- path: string;
409
- headers: { [key: string]: string };
410
- query: { [key: string]: unknown };
411
- body: unknown;
412
- responseType?: ResponseType;
413
- }
414
-
415
- export interface HttpBackend {
416
- request<T>(request: HttpBackendRequest): Promise<T>;
417
- }
418
-
419
- export abstract class HttpService {
420
- constructor(protected readonly httpBackend: HttpBackend) {}
421
- }
422
- """
423
- )
424
728
  processed_types: set[Any] = set()
425
729
  backlog: set[Any] = mapped_types_set.copy()
426
730
 
@@ -493,11 +797,16 @@ def is_primitive(field_type: Any) -> bool:
493
797
 
494
798
  def write_rest_controller_to_typescript_interface(
495
799
  rest_controller: RestController, controller: type
496
- ) -> tuple[str, set[Any]]:
800
+ ) -> tuple[StringIO, set[Any], StringIO | None]:
801
+
802
+ class_name = controller.__name__
803
+
804
+ decorated_queries: list[tuple[str, FunctionType, QueryEndpoint]] = []
805
+ decorated_mutations: list[tuple[str, FunctionType]] = []
497
806
 
498
807
  class_buffer = StringIO()
499
808
 
500
- class_buffer.write(f"export class {controller.__name__} extends HttpService {{\n")
809
+ class_buffer.write(f"export class {class_name} extends HttpService {{\n")
501
810
 
502
811
  mapped_types: set[Any] = set()
503
812
 
@@ -513,9 +822,15 @@ def write_rest_controller_to_typescript_interface(
513
822
  if return_type is None:
514
823
  return_type = NoneType
515
824
 
825
+ if query_endpoint := QueryEndpoint.extract_query_endpoint(member):
826
+ decorated_queries.append((name, member, query_endpoint))
827
+ if MutationEndpoint.is_mutation(member):
828
+ decorated_mutations.append((name, member))
829
+
516
830
  mapped_types.update(extract_all_envolved_types(return_type))
517
831
 
518
- return_value_repr = get_field_type_for_ts(return_type)
832
+ # For return types, use Output suffix if it's a split model
833
+ return_value_repr = get_field_type_for_ts(return_type, "Output")
519
834
 
520
835
  arg_params_spec, parametes_mapped_types = extract_parameters(
521
836
  member, rest_controller, mapping
@@ -538,10 +853,31 @@ def write_rest_controller_to_typescript_interface(
538
853
  class_buffer.write(f'\t\t\tmethod: "{mapping.method}",\n')
539
854
 
540
855
  endpoint_path = parse_path_with_params(mapping.path, arg_params_spec)
541
- final_path = "/".join(
542
- s.strip("/") for s in [rest_controller.path, endpoint_path]
856
+
857
+ # Properly handle path joining to avoid double slashes
858
+ controller_path = rest_controller.path or ""
859
+ path_parts = []
860
+
861
+ if controller_path and controller_path.strip("/"):
862
+ path_parts.append(controller_path.strip("/"))
863
+ if endpoint_path and endpoint_path.strip("/"):
864
+ path_parts.append(endpoint_path.strip("/"))
865
+
866
+ final_path = "/".join(path_parts) if path_parts else ""
867
+ # Ensure the path starts with a single slash
868
+ formatted_path = f"/{final_path}" if final_path else "/"
869
+
870
+ class_buffer.write(f"\t\t\tpath: `{formatted_path}`,\n")
871
+
872
+ # Sort path params
873
+ path_params = sorted(
874
+ [param for param in arg_params_spec if param.type_ == "path"],
875
+ key=lambda x: x.name,
543
876
  )
544
- class_buffer.write(f"\t\t\tpath: `/{final_path}`,\n")
877
+ class_buffer.write("\t\t\tpathParams: {\n")
878
+ for param in path_params:
879
+ class_buffer.write(f'\t\t\t\t"{param.name}": {param.name},\n')
880
+ class_buffer.write("\t\t\t},\n")
545
881
 
546
882
  # Sort headers
547
883
  header_params = sorted(
@@ -563,10 +899,25 @@ def write_rest_controller_to_typescript_interface(
563
899
  class_buffer.write(f'\t\t\t\t"{param.name}": {param.name},\n')
564
900
  class_buffer.write("\t\t\t},\n")
565
901
 
566
- if (
567
- body := next((x for x in arg_params_spec if x.type_ == "body"), None)
568
- ) is not None:
569
- class_buffer.write(f"\t\t\tbody: {body.name}\n")
902
+ # Check if we need to use FormData (for file uploads or form parameters)
903
+ form_params = [param for param in arg_params_spec if param.type_ == "form"]
904
+ body_param = next((x for x in arg_params_spec if x.type_ == "body"), None)
905
+
906
+ if form_params:
907
+ # Use FormData for file uploads and form parameters
908
+ class_buffer.write("\t\t\tbody: makeFormData({\n")
909
+
910
+ # Add form parameters (including file uploads)
911
+ for param in form_params:
912
+ class_buffer.write(f'\t\t\t\t"{param.name}": {param.name},\n')
913
+
914
+ # Add body parameter if it exists alongside form params
915
+ if body_param:
916
+ class_buffer.write(f"\t\t\t\t...{body_param.name},\n")
917
+
918
+ class_buffer.write("\t\t\t})\n")
919
+ elif body_param is not None:
920
+ class_buffer.write(f"\t\t\tbody: {body_param.name}\n")
570
921
  else:
571
922
  class_buffer.write("\t\t\tbody: undefined\n")
572
923
 
@@ -577,7 +928,44 @@ def write_rest_controller_to_typescript_interface(
577
928
 
578
929
  class_buffer.write("}\n")
579
930
 
580
- return class_buffer.getvalue(), mapped_types
931
+ controller_hooks_builder: StringIO | None = None
932
+
933
+ if decorated_queries or decorated_mutations:
934
+ controller_hooks_builder = StringIO()
935
+ controller_hooks_builder.write(
936
+ f"export const {pascal_to_camel(class_name)} = {{\n"
937
+ )
938
+
939
+ if decorated_queries:
940
+ controller_hooks_builder.write(
941
+ f"\t...createClassQueryHooks({class_name},\n"
942
+ )
943
+ for name, member, _ in decorated_queries:
944
+ controller_hooks_builder.write(f'\t\t"{snake_to_camel(name)}",\n')
945
+ controller_hooks_builder.write("\t),\n")
946
+
947
+ if decorated_queries and any(
948
+ query.has_infinite_query for _, _, query in decorated_queries
949
+ ):
950
+ controller_hooks_builder.write(
951
+ f"\t...createClassInfiniteQueryHooks({class_name}, {{\n"
952
+ )
953
+ for name, member, query in decorated_queries:
954
+ if query.has_infinite_query:
955
+ controller_hooks_builder.write(
956
+ f'\t\t"{snake_to_camel(name)}": paginationModelByFirstArgPaginationFilter(),\n'
957
+ )
958
+ controller_hooks_builder.write("\t}),\n")
959
+ if decorated_mutations:
960
+ controller_hooks_builder.write(
961
+ f"\t...createClassMutationHooks({class_name},\n"
962
+ )
963
+ for name, member in decorated_mutations:
964
+ controller_hooks_builder.write(f'\t\t"{snake_to_camel(name)}",\n')
965
+ controller_hooks_builder.write("\t),\n")
966
+ controller_hooks_builder.write("};\n")
967
+
968
+ return class_buffer, mapped_types, controller_hooks_builder
581
969
 
582
970
 
583
971
  EXCLUDED_REQUESTS_TYPES = [Request, Response]
@@ -585,15 +973,26 @@ EXCLUDED_REQUESTS_TYPES = [Request, Response]
585
973
 
586
974
  @dataclass
587
975
  class HttpParemeterSpec:
588
- type_: Literal["query", "path", "body", "header", "cookie"]
976
+ type_: Literal["query", "path", "body", "header", "cookie", "form"]
589
977
  name: str
590
978
  required: bool
591
979
  argument_type_str: str
592
980
 
593
981
 
594
982
  def parse_path_with_params(path: str, parameters: list[HttpParemeterSpec]) -> str:
983
+ # Use a regular expression to match both simple parameters {param} and
984
+ # parameters with converters {param:converter}
985
+ import re
986
+
987
+ pattern = re.compile(r"{([^:}]+)(?::[^}]*)?}")
988
+
989
+ # For each parameter found in the path, replace it with :param format
595
990
  for parameter in parameters:
596
- path = path.replace(f"{{{parameter.name}}}", f"${{{parameter.name}}}")
991
+ path = pattern.sub(
992
+ lambda m: f":{m.group(1)}" if m.group(1) == parameter.name else m.group(0),
993
+ path,
994
+ )
995
+
597
996
  return path
598
997
 
599
998
 
@@ -622,15 +1021,16 @@ def extract_parameters(
622
1021
  if is_primitive(member):
623
1022
 
624
1023
  if get_origin(member) is Annotated:
1024
+ unwrapped_type, all_metadata = unwrap_annotated_type(member)
625
1025
  if (
626
1026
  plain_validator := next(
627
- (x for x in member.__metadata__ if isinstance(x, PlainValidator)),
1027
+ (x for x in all_metadata if isinstance(x, PlainValidator)),
628
1028
  None,
629
1029
  )
630
1030
  ) is not None:
631
1031
  mapped_types.add(plain_validator.json_schema_input_type)
632
1032
  return parameters_list, mapped_types
633
- return extract_parameters(member.__args__[0], controller, mapping)
1033
+ return extract_parameters(unwrapped_type, controller, mapping)
634
1034
  return parameters_list, mapped_types
635
1035
 
636
1036
  if hasattr(member, "__bases__"):
@@ -650,8 +1050,21 @@ def extract_parameters(
650
1050
  continue
651
1051
 
652
1052
  if get_origin(parameter_type) == Annotated:
653
- annotated_type_hook = parameter_type.__metadata__[0]
654
- annotated_type = parameter_type.__args__[0]
1053
+ unwrapped_type, all_metadata = unwrap_annotated_type(parameter_type)
1054
+ # Look for FastAPI parameter annotations in all metadata layers
1055
+ annotated_type_hook = None
1056
+ for metadata in all_metadata:
1057
+ if isinstance(
1058
+ metadata, (Header, Cookie, Form, Body, Query, Path, Depends)
1059
+ ):
1060
+ annotated_type_hook = metadata
1061
+ break
1062
+
1063
+ if annotated_type_hook is None and all_metadata:
1064
+ # Fallback to first metadata if no FastAPI annotation found
1065
+ annotated_type_hook = all_metadata[0]
1066
+
1067
+ annotated_type = unwrapped_type
655
1068
  if isinstance(annotated_type_hook, Header):
656
1069
  mapped_types.add(str)
657
1070
  parameters_list.append(
@@ -672,34 +1085,80 @@ def extract_parameters(
672
1085
  argument_type_str=get_field_type_for_ts(str),
673
1086
  )
674
1087
  )
1088
+ elif isinstance(annotated_type_hook, Form):
1089
+ mapped_types.add(annotated_type)
1090
+ parameters_list.append(
1091
+ HttpParemeterSpec(
1092
+ type_="form",
1093
+ name=parameter_name,
1094
+ required=True,
1095
+ argument_type_str=get_field_type_for_ts(annotated_type),
1096
+ )
1097
+ )
675
1098
  elif isinstance(annotated_type_hook, Body):
676
1099
  mapped_types.update(extract_all_envolved_types(parameter_type))
1100
+ # For body parameters, use Input suffix if it's a split model
1101
+ context_suffix = (
1102
+ "Input"
1103
+ if (
1104
+ inspect.isclass(parameter_type)
1105
+ and hasattr(parameter_type, "__dict__")
1106
+ and SplitInputOutput.is_split_model(parameter_type)
1107
+ )
1108
+ else ""
1109
+ )
677
1110
  parameters_list.append(
678
1111
  HttpParemeterSpec(
679
1112
  type_="body",
680
1113
  name=parameter_name,
681
1114
  required=True,
682
- argument_type_str=get_field_type_for_ts(parameter_type),
1115
+ argument_type_str=get_field_type_for_ts(
1116
+ parameter_type, context_suffix
1117
+ ),
683
1118
  )
684
1119
  )
685
1120
  elif isinstance(annotated_type_hook, Query):
686
1121
  mapped_types.add(parameter_type)
1122
+ # For query parameters, use Input suffix if it's a split model
1123
+ context_suffix = (
1124
+ "Input"
1125
+ if (
1126
+ inspect.isclass(parameter_type)
1127
+ and hasattr(parameter_type, "__dict__")
1128
+ and SplitInputOutput.is_split_model(parameter_type)
1129
+ )
1130
+ else ""
1131
+ )
687
1132
  parameters_list.append(
688
1133
  HttpParemeterSpec(
689
1134
  type_="query",
690
1135
  name=parameter_name,
691
1136
  required=True,
692
- argument_type_str=get_field_type_for_ts(parameter_type),
1137
+ argument_type_str=get_field_type_for_ts(
1138
+ parameter_type, context_suffix
1139
+ ),
693
1140
  )
694
1141
  )
695
1142
  elif isinstance(annotated_type_hook, Path):
696
1143
  mapped_types.add(parameter_type)
1144
+ # For path parameters, use Input suffix if it's a split model
1145
+ context_suffix = (
1146
+ "Input"
1147
+ if (
1148
+ inspect.isclass(parameter_type)
1149
+ and hasattr(parameter_type, "__dict__")
1150
+ and SplitInputOutput.is_split_model(parameter_type)
1151
+ )
1152
+ else ""
1153
+ )
697
1154
  parameters_list.append(
698
1155
  HttpParemeterSpec(
699
1156
  type_="path",
700
1157
  name=parameter_name,
701
1158
  required=True,
702
- argument_type_str=get_field_type_for_ts(parameter_type),
1159
+ argument_type_str=get_field_type_for_ts(
1160
+ parameter_type, context_suffix
1161
+ ),
703
1162
  )
704
1163
  )
705
1164
 
@@ -717,62 +1176,152 @@ def extract_parameters(
717
1176
  )
718
1177
  mapped_types.update(rec_mapped_types)
719
1178
  parameters_list.extend(rec_parameters)
720
- elif controller.path.find(f":{parameter_name}") != -1:
1179
+ elif (
1180
+ re.search(f":{parameter_name}(?:/|$)", controller.path) is not None
1181
+ ):
721
1182
  mapped_types.add(annotated_type)
1183
+ # For path parameters, use Input suffix if it's a split model
1184
+ context_suffix = (
1185
+ "Input"
1186
+ if (
1187
+ inspect.isclass(annotated_type)
1188
+ and hasattr(annotated_type, "__dict__")
1189
+ and SplitInputOutput.is_split_model(annotated_type)
1190
+ )
1191
+ else ""
1192
+ )
722
1193
  parameters_list.append(
723
1194
  HttpParemeterSpec(
724
1195
  type_="path",
725
1196
  name=parameter_name,
726
1197
  required=True,
727
- argument_type_str=get_field_type_for_ts(annotated_type),
1198
+ argument_type_str=get_field_type_for_ts(
1199
+ annotated_type, context_suffix
1200
+ ),
728
1201
  )
729
1202
  )
730
1203
  else:
731
1204
  mapped_types.add(annotated_type)
732
- parameters_list.append(
733
- HttpParemeterSpec(
734
- type_="query",
735
- name=parameter_name,
736
- required=True,
737
- argument_type_str=get_field_type_for_ts(annotated_type),
1205
+ # Special handling for UploadFile and list[UploadFile] - should be treated as form data
1206
+ if is_upload_file_type(annotated_type):
1207
+ parameters_list.append(
1208
+ HttpParemeterSpec(
1209
+ type_="form",
1210
+ name=parameter_name,
1211
+ required=True,
1212
+ argument_type_str=get_field_type_for_ts(annotated_type),
1213
+ )
1214
+ )
1215
+ else:
1216
+ # For default parameters (treated as query), use Input suffix if it's a split model
1217
+ context_suffix = (
1218
+ "Input"
1219
+ if (
1220
+ inspect.isclass(annotated_type)
1221
+ and hasattr(annotated_type, "__dict__")
1222
+ and SplitInputOutput.is_split_model(annotated_type)
1223
+ )
1224
+ else ""
1225
+ )
1226
+ parameters_list.append(
1227
+ HttpParemeterSpec(
1228
+ type_="query",
1229
+ name=parameter_name,
1230
+ required=True,
1231
+ argument_type_str=get_field_type_for_ts(
1232
+ annotated_type, context_suffix
1233
+ ),
1234
+ )
738
1235
  )
739
- )
740
1236
 
741
1237
  elif inspect.isclass(parameter_type) and issubclass(
742
1238
  parameter_type, BaseModel
743
1239
  ):
744
1240
  mapped_types.update(extract_all_envolved_types(parameter_type))
1241
+ # For BaseModel parameters, use Input suffix if it's a split model
1242
+ context_suffix = (
1243
+ "Input" if SplitInputOutput.is_split_model(parameter_type) else ""
1244
+ )
745
1245
  parameters_list.append(
746
1246
  HttpParemeterSpec(
747
1247
  type_="body",
748
1248
  name=parameter_name,
749
1249
  required=True,
750
- argument_type_str=get_field_type_for_ts(parameter_type),
1250
+ argument_type_str=get_field_type_for_ts(
1251
+ parameter_type, context_suffix
1252
+ ),
751
1253
  )
752
1254
  )
753
- elif (
754
- controller.path.find(f"{{{parameter_name}}}") != -1
755
- or mapping.path.find(f"{{{parameter_name}}}") != -1
756
- ):
1255
+ elif parameter_type == UploadFile or is_upload_file_type(parameter_type):
1256
+ # UploadFile and list[UploadFile] should be treated as form data
757
1257
  mapped_types.add(parameter_type)
758
1258
  parameters_list.append(
759
1259
  HttpParemeterSpec(
760
- type_="path",
1260
+ type_="form",
761
1261
  name=parameter_name,
762
1262
  required=True,
763
1263
  argument_type_str=get_field_type_for_ts(parameter_type),
764
1264
  )
765
1265
  )
766
- else:
1266
+ elif (
1267
+ # Match both simple parameters {param} and parameters with converters {param:converter}
1268
+ re.search(f"{{{parameter_name}(:.*?)?}}", controller.path) is not None
1269
+ or re.search(f"{{{parameter_name}(:.*?)?}}", mapping.path) is not None
1270
+ ):
767
1271
  mapped_types.add(parameter_type)
1272
+ # For path parameters, use Input suffix if it's a split model
1273
+ context_suffix = (
1274
+ "Input"
1275
+ if (
1276
+ inspect.isclass(parameter_type)
1277
+ and hasattr(parameter_type, "__dict__")
1278
+ and SplitInputOutput.is_split_model(parameter_type)
1279
+ )
1280
+ else ""
1281
+ )
768
1282
  parameters_list.append(
769
1283
  HttpParemeterSpec(
770
- type_="query",
1284
+ type_="path",
771
1285
  name=parameter_name,
772
1286
  required=True,
773
- argument_type_str=get_field_type_for_ts(parameter_type),
1287
+ argument_type_str=get_field_type_for_ts(
1288
+ parameter_type, context_suffix
1289
+ ),
774
1290
  )
775
1291
  )
1292
+ else:
1293
+ mapped_types.add(parameter_type)
1294
+ # Special handling for UploadFile and list[UploadFile] - should be treated as form data
1295
+ if is_upload_file_type(parameter_type):
1296
+ parameters_list.append(
1297
+ HttpParemeterSpec(
1298
+ type_="form",
1299
+ name=parameter_name,
1300
+ required=True,
1301
+ argument_type_str=get_field_type_for_ts(parameter_type),
1302
+ )
1303
+ )
1304
+ else:
1305
+ # For default parameters (treated as query), use Input suffix if it's a split model
1306
+ context_suffix = (
1307
+ "Input"
1308
+ if (
1309
+ inspect.isclass(parameter_type)
1310
+ and hasattr(parameter_type, "__dict__")
1311
+ and SplitInputOutput.is_split_model(parameter_type)
1312
+ )
1313
+ else ""
1314
+ )
1315
+ parameters_list.append(
1316
+ HttpParemeterSpec(
1317
+ type_="query",
1318
+ name=parameter_name,
1319
+ required=True,
1320
+ argument_type_str=get_field_type_for_ts(
1321
+ parameter_type, context_suffix
1322
+ ),
1323
+ )
1324
+ )
776
1325
 
777
1326
  if inspect.isclass(parameter_type) and not is_primitive(parameter_type):
778
1327
  signature = inspect.signature(parameter_type)
@@ -782,21 +1331,22 @@ def extract_parameters(
782
1331
  for _, parameter_type in parameter_members.items():
783
1332
  if is_primitive(parameter_type.annotation):
784
1333
  if get_origin(parameter_type.annotation) is not None:
785
- if (
786
- get_origin(parameter_type.annotation) == Annotated
787
- and (
788
- plain_validator := next(
789
- (
790
- x
791
- for x in parameter_type.annotation.__metadata__
792
- if isinstance(x, PlainValidator)
793
- ),
794
- None,
795
- )
1334
+ if get_origin(parameter_type.annotation) == Annotated:
1335
+ unwrapped_type, all_metadata = unwrap_annotated_type(
1336
+ parameter_type.annotation
1337
+ )
1338
+ plain_validator = next(
1339
+ (
1340
+ x
1341
+ for x in all_metadata
1342
+ if isinstance(x, PlainValidator)
1343
+ ),
1344
+ None,
796
1345
  )
797
- is not None
798
- ):
799
- mapped_types.add(plain_validator.json_schema_input_type)
1346
+ if plain_validator is not None:
1347
+ mapped_types.add(
1348
+ plain_validator.json_schema_input_type
1349
+ )
800
1350
  else:
801
1351
  args = parameter_type.annotation.__args__
802
1352
  mapped_types.update(args)
@@ -827,22 +1377,15 @@ def extract_all_envolved_types(field_type: Any) -> set[Any]:
827
1377
 
828
1378
  if is_primitive(field_type):
829
1379
  if get_origin(field_type) is not None:
830
- if (
831
- get_origin(field_type) == Annotated
832
- and (
833
- plain_validator := next(
834
- (
835
- x
836
- for x in field_type.__metadata__
837
- if isinstance(x, PlainValidator)
838
- ),
839
- None,
840
- )
1380
+ if get_origin(field_type) == Annotated:
1381
+ unwrapped_type, all_metadata = unwrap_annotated_type(field_type)
1382
+ plain_validator = next(
1383
+ (x for x in all_metadata if isinstance(x, PlainValidator)),
1384
+ None,
841
1385
  )
842
- is not None
843
- ):
844
- mapped_types.add(plain_validator.json_schema_input_type)
845
- return mapped_types
1386
+ if plain_validator is not None:
1387
+ mapped_types.add(plain_validator.json_schema_input_type)
1388
+ return mapped_types
846
1389
  else:
847
1390
  mapped_types.update(
848
1391
  *[extract_all_envolved_types(arg) for arg in field_type.__args__]