1+ """
2+ App router for Stacksync Apps defined via stacksync.yml files.
3+ This file is NOT intended to be used directly. It is injected into the build output folder
4+ when users run `stacksync dev` or `stacksync deploy`.
5+ """
6+
7+ from __future__ import annotations
8+
9+ import importlib .util
10+ import logging
11+ import os
12+ from typing import Any
13+
14+ import yaml
15+ from flask import Flask , current_app , jsonify , request
16+
17+ # Paths are relative to the build output root (e.g. .stacksync_build)
18+ ROOT = os .path .dirname (os .path .abspath (__file__ ))
19+ MODULES_DIR = os .path .join (ROOT , "modules" )
20+
21+
22+
23+ def load_stacksync_yml () -> dict [str , Any ]:
24+ path = os .path .join (ROOT , "stacksync.yml" )
25+ with open (path , "r" , encoding = "utf-8" ) as f :
26+ return yaml .safe_load (f ) or {}
27+
28+
29+ def import_module_from_path (module_name : str , file_path : str ):
30+ """Load modules/acme_crm_create_record/schema.py as a distinct Python module."""
31+ spec = importlib .util .spec_from_file_location (module_name , file_path )
32+ if spec is None or spec .loader is None :
33+ raise ImportError (f"Cannot load { file_path } " )
34+ mod = importlib .util .module_from_spec (spec )
35+ spec .loader .exec_module (mod )
36+ return mod
37+
38+
39+ def create_app () -> Flask :
40+ if not logging .root .handlers :
41+ logging .basicConfig (level = logging .INFO , format = "%(levelname)s %(name)s: %(message)s" )
42+
43+ app = Flask (__name__ )
44+ app .logger .info ("Initializing connector (root=%s)" , ROOT )
45+
46+ config = load_stacksync_yml ()
47+ app .config ["STACKSYNC_YML" ] = config
48+
49+ app_settings = config .get ("app_settings" ) or {}
50+ app_name = app_settings .get ("app_name" , "(unnamed)" )
51+ app_type = app_settings .get ("app_type" , "(unknown)" )
52+ app .logger .info ("Loaded stacksync.yml: app_name=%r app_type=%r" , app_name , app_type )
53+
54+ modules_cfg : dict [str , Any ] = config .get ("modules" ) or {}
55+ if not modules_cfg :
56+ app .logger .warning ("No modules declared under stacksync.yml 'modules'" )
57+
58+ for module_id , module_meta in modules_cfg .items ():
59+ package_dir = os .path .join (MODULES_DIR , module_id )
60+ if not os .path .isdir (package_dir ):
61+ app .logger .warning ("Skipping module %r: no directory at %s" , module_id , package_dir )
62+ continue
63+
64+ prefix = f"/modules/{ module_id } "
65+ registered : list [str ] = []
66+
67+ # schema.py → schema_handler(form_data, credentials)
68+ schema_path = os .path .join (package_dir , "schema.py" )
69+ if os .path .isfile (schema_path ):
70+ smod = import_module_from_path (f"{ module_id } _schema" , schema_path )
71+
72+ @app .route (f"{ prefix } /schema" , methods = ["GET" , "POST" ])
73+ def schema_route (
74+ _smod = smod ,
75+ _meta = module_meta ,
76+ _module_id = module_id ,
77+ ):
78+ current_app .logger .info ("Schema handler called (module=%s)" , _module_id )
79+ payload = request .get_json (silent = True ) or {}
80+ form_data = payload .get ("form_data" ) or {}
81+ credentials = payload .get ("credentials" )
82+ result = _smod .schema_handler (form_data , credentials )
83+ # schema_handler may return a Schema (dict subclass) or plain dict
84+ return jsonify ({"schema" : dict (result ) if hasattr (result , "keys" ) else result })
85+
86+ registered .append ("schema" )
87+
88+ # content.py → content_handler(form_data, credentials, content_object_names)
89+ content_path = os .path .join (package_dir , "content.py" )
90+ if os .path .isfile (content_path ):
91+ cmod = import_module_from_path (f"{ module_id } _content" , content_path )
92+
93+ @app .route (f"{ prefix } /content" , methods = ["POST" ])
94+ def content_route (_cmod = cmod , _module_id = module_id ):
95+ current_app .logger .info ("Content handler called (module=%s)" , _module_id )
96+ payload = request .get_json (silent = True ) or {}
97+ form_data = payload .get ("form_data" ) or {}
98+ credentials = payload .get ("credentials" )
99+ names = payload .get ("content_object_names" ) or []
100+ result = _cmod .content_handler (form_data , credentials , names )
101+ return jsonify (result )
102+
103+ registered .append ("content" )
104+
105+ # execute.py → execute_handler(input, credentials)
106+ execute_path = os .path .join (package_dir , "execute.py" )
107+ if os .path .isfile (execute_path ):
108+ emod = import_module_from_path (f"{ module_id } _execute" , execute_path )
109+
110+ @app .route (f"{ prefix } /execute" , methods = ["POST" ])
111+ def execute_route (_emod = emod , _module_id = module_id ):
112+ current_app .logger .info ("Execute handler called (module=%s)" , _module_id )
113+ payload = request .get_json (silent = True ) or {}
114+ data = payload .get ("data" ) or {}
115+ credentials = payload .get ("credentials" ) or {}
116+ result = _emod .execute_handler (data , credentials )
117+ return jsonify (result )
118+
119+ registered .append ("execute" )
120+
121+ if registered :
122+ app .logger .info (
123+ "Registered module %r: %s" ,
124+ module_id ,
125+ ", " .join (registered ),
126+ )
127+ else :
128+ app .logger .warning (
129+ "Module %r: no schema.py, content.py, or execute.py found" ,
130+ module_id ,
131+ )
132+
133+ @app .get ("/health" )
134+ def health ():
135+ return jsonify ({"status" : "ok" })
136+
137+ app .logger .info ("Routes ready (includes GET /health)" )
138+ return app
139+
140+
141+ app = create_app ()
142+
143+ if __name__ == "__main__" :
144+ app .run (host = "127.0.0.1" , port = 5000 , debug = True )
0 commit comments