Coverage for config/api.py: 81%

157 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-04 17:24 +0000

1"""Django Ninja API root. 

2 

3Mounted at ``/api`` by ``config/urls.py``. Capability routers are added per sprint. 

4The OpenAPI schema exposed at ``/api/openapi.json`` is the source for the generated 

5TypeScript client (``frontend/packages/api-client``). 

6""" 

7 

8import logging 

9 

10from django.core.exceptions import ( 

11 ObjectDoesNotExist, 

12 PermissionDenied, 

13) 

14from django.core.exceptions import ( 

15 ValidationError as DjangoValidationError, 

16) 

17from django.db import DatabaseError, DataError, IntegrityError 

18from django.http import Http404 

19from ninja import NinjaAPI 

20from ninja.errors import ValidationError as NinjaValidationError 

21 

22from apps.accounts.admin_api import ( 

23 admin_console_router, 

24 billing_router, 

25 compliance_router, 

26 provision_router, 

27) 

28from apps.accounts.admin_api import router as accounts_router 

29from apps.accounts.directory_api import router as directory_router 

30from apps.accounts.local_auth_api import router as local_auth_router 

31from apps.accounts.setup_api import router as setup_router 

32from apps.accounts.tireless_api import router as tireless_router 

33from apps.application_1003.api import router as financials_router 

34from apps.borrower_portal.api import router as borrower_auth_router 

35from apps.borrower_portal.invite_api import router as pos_invite_router 

36from apps.borrower_portal.pos_api import geocode_router 

37from apps.borrower_portal.pos_api import router as pos_router 

38from apps.borrower_portal.upload_api import ( 

39 portal_router as pos_documents_router, 

40) 

41from apps.borrower_portal.upload_api import public_router as upload_public_router 

42from apps.borrower_portal.upload_api import revoke_router as upload_revoke_router 

43from apps.borrower_portal.upload_api import staff_router as upload_invites_router 

44from apps.client_needs.api import classifications_router 

45from apps.client_needs.api import router as client_needs_router 

46from apps.communications.api import router as communications_router 

47from apps.communications.webhooks import router as webhooks_router 

48from apps.core_los.api import router as loans_router 

49from apps.core_los.contacts_api import router as contacts_router 

50from apps.core_los.custom_fields_api import custom_fields_router, loan_custom_fields_router 

51from apps.core_los.dashboard_api import router as dashboard_router 

52from apps.core_los.leads_api import router as leads_router 

53from apps.core_los.leads_search_api import router as leads_search_router 

54from apps.core_los.public_api import router as public_router 

55from apps.core_los.report_builder_api import router as report_builder_router 

56from apps.core_los.reports_api import router as reports_router 

57from apps.core_los.saved_views_api import router as saved_views_router 

58from apps.disclosures.api import router as disclosures_router 

59from apps.fees_trid.api import router as fees_router 

60from apps.integrations.admin_api import router as admin_integrations_router 

61from apps.integrations.settings_api import router as settings_router 

62from apps.mismo.api import router as mismo_router 

63from apps.pricing_ppe.api import router as pricing_router 

64from apps.underwriting.api import router as credit_router 

65from apps.underwriting.aus_api import router as aus_router 

66from apps.underwriting.condition_api import router as condition_router 

67from apps.workflow.api import config_router as workflow_config_router 

68from apps.workflow.api import router as workflow_router 

69from apps.workflow.api import task_templates_router 

70from apps.workflow.rules_api import router as rules_router 

71from apps.workflow.tasks_hub_api import router as tasks_hub_router 

72from domain.result import AppError, DomainError, format_db_error, to_envelope 

73 

74logger = logging.getLogger(__name__) 

75 

76api = NinjaAPI( 

77 title="Lumen Lending OS API", 

78 version="0.1.0", 

79 description="Enterprise mortgage loan origination platform API.", 

80) 

81 

82 

83@api.exception_handler(DomainError) 

84def _domain_error_handler(request, exc: DomainError): 

85 """Render every governed domain error as the shared envelope.""" 

86 return api.create_response( 

87 request, to_envelope(exc.status_code, exc.errors), status=exc.status_code 

88 ) 

89 

90 

91@api.exception_handler(NinjaValidationError) 

92def _ninja_validation_error_handler(request, exc: NinjaValidationError): 

93 """Render Pydantic / Ninja request validation errors in the standard envelope.""" 

94 app_errors = [] 

95 for err in exc.errors: 

96 loc = err.get("loc", []) 

97 field_name = str(loc[-1]) if loc else "field" 

98 msg = err.get("msg", "Invalid value") 

99 clean_field = field_name.replace("_", " ").capitalize() 

100 app_errors.append( 

101 AppError("VALIDATION_ERROR", f"{clean_field}: {msg}", field=field_name) 

102 ) 

103 envelope = to_envelope(422, app_errors) 

104 envelope["detail"] = exc.errors 

105 return api.create_response(request, envelope, status=422) 

106 

107 

108@api.exception_handler(DjangoValidationError) 

109def _django_validation_error_handler(request, exc: DjangoValidationError): 

110 """Render Django model / form validation errors in the standard envelope.""" 

111 app_errors = [] 

112 if hasattr(exc, "message_dict"): 

113 for field_name, msgs in exc.message_dict.items(): 

114 clean_field = field_name.replace("_", " ").capitalize() 

115 for m in msgs: 

116 app_errors.append( 

117 AppError("VALIDATION_ERROR", f"{clean_field}: {m}", field=field_name) 

118 ) 

119 elif hasattr(exc, "messages"): 

120 for m in exc.messages: 

121 app_errors.append(AppError("VALIDATION_ERROR", m)) 

122 else: 

123 app_errors.append(AppError("VALIDATION_ERROR", str(exc))) 

124 return api.create_response(request, to_envelope(422, app_errors), status=422) 

125 

126 

127@api.exception_handler(DataError) 

128def _data_error_handler(request, exc: DataError): 

129 """Gracefully handle database numeric overflow or data length truncation.""" 

130 msg = format_db_error(exc) 

131 return api.create_response( 

132 request, 

133 to_envelope(422, [AppError("DATA_ERROR", msg)]), 

134 status=422, 

135 ) 

136 

137 

138@api.exception_handler(IntegrityError) 

139def _integrity_error_handler(request, exc: IntegrityError): 

140 """Gracefully handle database integrity / constraint violations.""" 

141 msg = format_db_error(exc) 

142 return api.create_response( 

143 request, 

144 to_envelope(422, [AppError("INTEGRITY_ERROR", msg)]), 

145 status=422, 

146 ) 

147 

148 

149@api.exception_handler(DatabaseError) 

150def _database_error_handler(request, exc: DatabaseError): 

151 """Gracefully handle unexpected database errors.""" 

152 logger.exception("Database error on %s %s: %s", request.method, request.path, exc) 

153 msg = format_db_error(exc) 

154 return api.create_response( 

155 request, 

156 to_envelope(422, [AppError("DATABASE_ERROR", msg)]), 

157 status=422, 

158 ) 

159 

160 

161@api.exception_handler(Http404) 

162def _http_404_handler(request, exc: Http404): 

163 """Render 404 Not Found in the standard envelope.""" 

164 return api.create_response( 

165 request, 

166 to_envelope(404, [AppError("NOT_FOUND", str(exc) or "The requested resource was not found.")]), 

167 status=404, 

168 ) 

169 

170 

171@api.exception_handler(ObjectDoesNotExist) 

172def _object_does_not_exist_handler(request, exc: ObjectDoesNotExist): 

173 """Render Django DoesNotExist in the standard envelope.""" 

174 return api.create_response( 

175 request, 

176 to_envelope(404, [AppError("NOT_FOUND", "The requested record was not found.")]), 

177 status=404, 

178 ) 

179 

180 

181@api.exception_handler(PermissionDenied) 

182def _permission_denied_handler(request, exc: PermissionDenied): 

183 """Render 403 Forbidden in the standard envelope.""" 

184 return api.create_response( 

185 request, 

186 to_envelope(403, [AppError("FORBIDDEN", str(exc) or "You do not have permission to perform this action.")]), 

187 status=403, 

188 ) 

189 

190 

191@api.exception_handler(Exception) 

192def _unhandled_exception_handler(request, exc: Exception): 

193 """Global catch-all preventing raw tracebacks or code errors from leaking to API clients.""" 

194 logger.exception("Unhandled error processing %s %s: %s", request.method, request.path, exc) 

195 return api.create_response( 

196 request, 

197 to_envelope( 

198 500, 

199 [AppError("SERVER_ERROR", "An unexpected error occurred while processing your request. Please check your input and try again.")], 

200 ), 

201 status=500, 

202 ) 

203 

204 

205@api.get("/health", tags=["system"]) 

206def health(request): 

207 """Liveness probe and contract-bridge smoke endpoint.""" 

208 return {"status": "ok", "service": "lumen-backend"} 

209 

210 

211api.add_router("/loans", loans_router) 

212api.add_router("/loans", financials_router) 

213api.add_router("/loans", credit_router) 

214api.add_router("/loans", aus_router) 

215api.add_router("/loans", condition_router) 

216api.add_router("/loans", client_needs_router) 

217api.add_router("/loans", pricing_router) 

218api.add_router("/loans", fees_router) 

219api.add_router("/loans", workflow_router) 

220api.add_router("/loans", mismo_router) 

221api.add_router("/loans", disclosures_router) 

222api.add_router("/loans", loan_custom_fields_router) 

223api.add_router("/loans", pos_invite_router) 

224api.add_router("/loans", upload_invites_router) 

225api.add_router("/workflow", workflow_config_router) 

226api.add_router("/accounts", accounts_router) 

227api.add_router("/admin", admin_integrations_router) 

228api.add_router("/admin", admin_console_router) 

229api.add_router("/settings", settings_router) 

230api.add_router("/public", public_router) 

231api.add_router("/setup", setup_router) 

232api.add_router("", tireless_router) 

233api.add_router("", dashboard_router) 

234api.add_router("", tasks_hub_router) 

235api.add_router("", saved_views_router) 

236api.add_router("", contacts_router) 

237api.add_router("", directory_router) 

238api.add_router("", leads_search_router) 

239api.add_router("", leads_router) 

240api.add_router("", reports_router) 

241api.add_router("", report_builder_router) 

242api.add_router("", classifications_router) 

243api.add_router("", communications_router) 

244api.add_router("", webhooks_router) 

245api.add_router("", rules_router) 

246api.add_router("", custom_fields_router) 

247api.add_router("", local_auth_router) 

248api.add_router("", borrower_auth_router) 

249api.add_router("/pos", pos_router) 

250api.add_router("/pos", pos_documents_router) 

251api.add_router("", upload_revoke_router) 

252api.add_router("", upload_public_router) 

253api.add_router("", geocode_router) 

254api.add_router("", task_templates_router) 

255api.add_router("", compliance_router) 

256api.add_router("", billing_router) 

257api.add_router("", provision_router)