Coverage for seedboxsync/front/apis/resources.py: 97%

30 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 17:46 +0000

1# 

2# Copyright (C) 2015-2026 Guillaume Kulakowski <guillaume@kulakowski.fr> 

3# 

4# For the full copyright and license information, please view the LICENSE 

5# file that was distributed with this source code. 

6# 

7"""SeedboxSync front RestXResource module.""" 

8 

9from datetime import datetime 

10from typing import Any 

11import uuid 

12from flask_restx import Model, Namespace, OrderedModel, Resource as RestXResource, fields 

13 

14 

15class Resource(RestXResource): # type: ignore[misc] 

16 """ 

17 Base Resource class extending Flask-RestX Resource. 

18 

19 Provides utility methods for: 

20 - enforcing limits on query parameters 

21 - building consistent API response envelopes 

22 - generating envelope models for Swagger documentation 

23 """ 

24 

25 def set_limit(self, limit: int) -> int: 

26 """ 

27 Clamp the limit value within the allowed range [5, 1000]. 

28 

29 Args: 

30 limit (int): The requested limit value. 

31 

32 Returns: 

33 int: The clamped limit within [5, 1000]. 

34 """ 

35 if limit < 5: 

36 return 5 

37 if limit > 1000: 

38 return 1000 

39 return limit 

40 

41 def build_envelope( 

42 self, 

43 data: Any, 

44 *, 

45 type: str = "about:blank", # noqa: A002 

46 status_code: int = 200, 

47 message: str | None = None, 

48 data_total: int | None = None, 

49 ) -> dict[str, Any]: 

50 """ 

51 Build a standard API response envelope. 

52 

53 Args: 

54 data (Any): The response payload. 

55 type (str): The resource type or endpoint identifier (default: 'about:blank'). 

56 status_code (int): HTTP status code (default: 200). 

57 message (str): Send message in place of data. 

58 data_total (int | None): Optional total number of items if the result is paginated. 

59 

60 Returns: 

61 dict[str, Any]: Structured API response containing metadata and payload. 

62 """ 

63 return { 

64 "type": type, 

65 "success": True, 

66 "status": status_code, 

67 "timestamp": datetime.now().astimezone().isoformat(), 

68 "traceId": str(uuid.uuid4()), 

69 **({"data": data} if data is not None else {}), 

70 **({"data_total": data_total} if data_total is not None else {}), 

71 **({"message": message} if message is not None else {}), 

72 "data": data, 

73 } 

74 

75 @staticmethod 

76 def build_envelope_model( 

77 api: Namespace, 

78 name: str, 

79 *, 

80 nested_model: Model | OrderedModel | None = None, 

81 as_list: bool = True, 

82 as_message: bool = False, 

83 ) -> Model | OrderedModel: 

84 """ 

85 Build a Flask-RestX model for a standard API response envelope. 

86 

87 This is used for Swagger documentation. You can generate an envelope 

88 for a single resource or for a list of resources. 

89 

90 Args: 

91 api (Namespace): The Flask-RestX namespace. 

92 name (str): The name of the nested resource model. 

93 nested_model (Model | OrderedModel): The Flask-RestX model representing the resource. 

94 as_list (bool): If True, the 'data' field will be a list of nested resources. 

95 as_message (bool): The response is a message only. 

96 

97 Returns: 

98 Model | OrderedModel: A new Flask-RestX model representing the envelope. 

99 """ 

100 if as_message is False: 

101 if as_list: 

102 data_field = fields.List( 

103 fields.Nested(nested_model), 

104 required=True, 

105 description=f"List of {name} objects", 

106 ) 

107 else: 

108 data_field = fields.Nested(nested_model, required=True, description=f"The {name} object") 

109 data_total = fields.Integer(required=False, description=f"Total of {name} object") 

110 message_field = None 

111 else: 

112 data_field = None 

113 data_total = None 

114 message_field = fields.String(required=False, description="Response message", example="All is OK") 

115 

116 return api.model( 

117 f"Envelope[{name}]", 

118 { 

119 "type": fields.String( 

120 required=True, 

121 description="Resource type or endpoint identifier", 

122 example=name, 

123 ), 

124 "success": fields.Boolean( 

125 required=True, 

126 description="Indicates if the request succeeded", 

127 example=True, 

128 ), 

129 "status": fields.Integer(required=True, description="HTTP status code", example=200), 

130 "timestamp": fields.DateTime(required=True, description="Timestamp of the response"), 

131 "traceId": fields.String( 

132 required=True, 

133 description="Unique trace identifier", 

134 example="0a8ab95e-a463-424e-bc6d-505503bf200d", 

135 ), 

136 **({"data": data_field} if data_field is not None else {}), 

137 **({"data_total": data_total} if data_total is not None else {}), 

138 **({"message": message_field} if message_field is not None else {}), 

139 }, 

140 ) 

141 

142 

143class DateTimeOrZero(fields.DateTime): # type: ignore[misc] 

144 """Format date-time values while preserving the legacy zero value.""" 

145 

146 def format(self, value: int | datetime) -> Any: 

147 """ 

148 Format a date-time value or return zero unchanged. 

149 

150 Args: 

151 value (int | datetime): Date-time value or the legacy zero sentinel. 

152 

153 Returns: 

154 Any: Formatted date-time value or zero. 

155 """ 

156 if value == 0: 

157 return 0 

158 return super().format(value)