Skip to content

Commit d1c2e33

Browse files
[IT-4485] Refactor to prep for adding new endpoint
Refactor the lambda into multiple submodules in preparation of adding a new API endpoint for getting the current balances. Also rename 'mips' to 'mip' to accurately reflect the name of the upstream service.
1 parent 1925576 commit d1c2e33

File tree

11 files changed

+635
-624
lines changed

11 files changed

+635
-624
lines changed

.coveragerc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
relative_files = True
33

44
# Use 'source' instead of 'omit' in order to ignore 'tests/unit/__init__.py'
5-
source = mips_api
5+
source = mip_api

.github/workflows/test.yaml

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,6 @@ on:
66
workflow_call:
77

88
jobs:
9-
pre-commit:
10-
runs-on: ubuntu-latest
11-
steps:
12-
- uses: actions/checkout@v3
13-
- name: Set up Python
14-
uses: actions/setup-python@v4
15-
with:
16-
python-version: 3.12
17-
- uses: pre-commit/action@v3.0.0
18-
199
pytest:
2010
runs-on: ubuntu-latest
2111
steps:

mip_api/__init__.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import json
2+
import logging
3+
4+
from mip_api import chart, s3, ssm, upstream, util
5+
6+
7+
LOG = logging.getLogger(__name__)
8+
LOG.setLevel(logging.DEBUG)
9+
10+
11+
def lambda_handler(event, context):
12+
"""Sample pure Lambda function
13+
14+
Parameters
15+
----------
16+
event: dict, required
17+
API Gateway Lambda Proxy Input Format
18+
19+
Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
20+
21+
context: object, required
22+
Lambda Context runtime methods and attributes
23+
24+
Context doc: https://docs.aws.amazon.com/lambda/latest/dg/python-context-object.html
25+
26+
Returns
27+
------
28+
API Gateway Lambda Proxy Output Format: dict
29+
30+
Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
31+
"""
32+
33+
# helper functions to encapsulate the body, headers, and status code
34+
def _build_return(code, body):
35+
return {
36+
"statusCode": code,
37+
"body": json.dumps(body, indent=2),
38+
}
39+
40+
try:
41+
# collect environment variables
42+
mip_org = util.get_os_var("MipsOrg")
43+
ssm_path = util.get_os_var("SsmPath")
44+
s3_bucket = util.get_os_var("CacheBucket")
45+
s3_path = util.get_os_var("CacheBucketPath")
46+
47+
code_other = util.get_os_var("OtherCode")
48+
code_no_program = util.get_os_var("NoProgramCode")
49+
50+
api_routes = {
51+
"ApiChartOfAccounts": util.get_os_var("ApiChartOfAccounts"),
52+
"ApiValidTags": util.get_os_var("ApiValidTags"),
53+
}
54+
55+
_to_omit = util.get_os_var("CodesToOmit")
56+
omit_codes_list = util.parse_codes(_to_omit)
57+
58+
# get secure parameters
59+
ssm_secrets = ssm.get_secrets(ssm_path)
60+
61+
# get chart of accounts from mip cloud
62+
raw_chart = chart.get_chart(mip_org, ssm_secrets, s3_bucket, s3_path)
63+
LOG.debug(f"Raw chart data: {raw_chart}")
64+
65+
# collect query-string parameters
66+
params = {}
67+
if "queryStringParameters" in event:
68+
params = event["queryStringParameters"]
69+
LOG.debug(f"Query-string parameters: {params}")
70+
71+
# parse the path and return appropriate data
72+
if "path" in event:
73+
event_path = event["path"]
74+
75+
# always process the chart of accounts
76+
mip_chart = chart.process_chart(
77+
params, raw_chart, omit_codes_list, code_other, code_no_program
78+
)
79+
80+
if event_path == api_routes["ApiChartOfAccounts"]:
81+
# conditionally limit the size of the output
82+
return_chart = chart.limit_chart(params, mip_chart)
83+
return _build_return(200, return_chart)
84+
85+
elif event_path == api_routes["ApiValidTags"]:
86+
# build a list of strings from the processed dictionary
87+
valid_tags = chart.list_tags(params, mip_chart)
88+
return _build_return(200, valid_tags)
89+
90+
else:
91+
return _build_return(404, {"error": "Invalid request path"})
92+
93+
return _build_return(400, {"error": f"Invalid event: No path found: {event}"})
94+
95+
except Exception as exc:
96+
LOG.exception(exc)
97+
return _build_return(500, {"error": str(exc)})

mip_api/chart.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import logging
2+
import re
3+
4+
from mip_api import s3, upstream, util
5+
6+
LOG = logging.getLogger(__name__)
7+
LOG.setLevel(logging.DEBUG)
8+
9+
10+
def get_chart(org_name, secrets, bucket, path):
11+
"""
12+
Access the Chart of Accounts from MIP Cloud, and implement a write-through
13+
cache of successful responses to tolerate long-term faults in the upstream
14+
API.
15+
16+
A successful API response will be stored in S3 indefinitely, to be retrieved
17+
and used in the case of an API failure.
18+
19+
The S3 bucket has versioning enabled for disaster recovery, but this means
20+
that every PUT request will create a new S3 object. In order to minimize
21+
the number of objects in the bucket, read the cache value on every run and
22+
only update the S3 object if it changes.
23+
"""
24+
25+
# get the upstream API response
26+
LOG.info("Read chart of accounts from upstream API")
27+
upstream_dict = upstream.program_chart(org_name, secrets)
28+
LOG.debug(f"Upstream API response: {upstream_dict}")
29+
30+
# always read cached value
31+
LOG.info("Read cached chart of accounts from S3")
32+
cache_dict = None
33+
try:
34+
cache_dict = s3.cache_read(bucket, path)
35+
LOG.debug(f"Cached API response: {cache_dict}")
36+
except Exception as exc:
37+
LOG.exception("S3 read failure")
38+
39+
if upstream_dict:
40+
# if we received a non-empty response from the upstream API, compare it
41+
# to our cached response and update the S3 write-through cache if needed
42+
if upstream_dict == cache_dict:
43+
LOG.debug("No change in chart of accounts")
44+
else:
45+
# store write-through cache
46+
LOG.info("Write updated chart of accounts to S3")
47+
try:
48+
s3.cache_write(upstream_dict, bucket, path)
49+
except Exception as exc:
50+
LOG.exception("S3 write failure")
51+
coa_dict = upstream_dict
52+
else:
53+
# no response (or an empty response) from the upstream API,
54+
# rely on our response cached in S3.
55+
coa_dict = cache_dict
56+
57+
if not coa_dict:
58+
# make sure we don't return an empty value
59+
raise ValueError("No valid chart of accounts found")
60+
61+
return coa_dict
62+
63+
64+
def process_chart(params, chart_dict, omit_list, other, no_program):
65+
"""
66+
Process chart of accounts to remove unneeded programs,
67+
and inject some extra (meta) programs.
68+
69+
5-digit codes are inactive and should be ignored in most cases.
70+
8-digit codes are active, but only the first 6 digits are significant,
71+
i.e. 12345601 and 12345602 should be deduplicated as 123456.
72+
"""
73+
74+
# deduplicate on shortened numeric codes
75+
# pre-populate with codes to omit to short-circuit their processing
76+
found_codes = []
77+
found_codes.extend(omit_list)
78+
79+
# output object
80+
out_chart = {}
81+
82+
# whether to filter out inactive codes
83+
code_len = 5
84+
if util.param_inactive_bool(params):
85+
code_len = 6
86+
87+
# optionally move this list of codes to the top of the output
88+
priority_codes = util.param_priority_list(params)
89+
90+
# add short codes
91+
for code, _name in chart_dict.items():
92+
if len(code) >= code_len:
93+
# truncate active codes to the first 6 significant digits
94+
short = code[:6]
95+
# enforce AWS tags limitations
96+
# https://docs.aws.amazon.com/tag-editor/latest/userguide/best-practices-and-strats.html
97+
# enforce removing special characters globally for consistency,
98+
# only enforce string limit when listing tag values because the string size will change.
99+
regex = r"[^\d\w\s.:/=+\-@]+"
100+
name = re.sub(regex, "", _name)
101+
102+
if short in found_codes:
103+
LOG.info(f"Code {short} has already been processed")
104+
continue
105+
106+
if priority_codes is not None:
107+
if short in priority_codes:
108+
# Since Python 3.7, python dictionaries preserve insertion
109+
# order, so to prepend an item to the top of the dictionary,
110+
# we create a new dictionary inserting the target code first,
111+
# then add the previous output, and finally save the new
112+
# dictionary as our output dictionary.
113+
new_chart = {short: name}
114+
new_chart.update(out_chart)
115+
out_chart = new_chart
116+
found_codes.append(short)
117+
else:
118+
out_chart[short] = name
119+
found_codes.append(short)
120+
else:
121+
out_chart[short] = name
122+
found_codes.append(short)
123+
124+
# inject "other" code
125+
if util.param_other_bool(params):
126+
new_chart = {other: "Other"}
127+
new_chart.update(out_chart)
128+
out_chart = new_chart
129+
130+
# inject "no program" code
131+
if util.param_no_program_bool(params):
132+
new_chart = {no_program: "No Program"}
133+
new_chart.update(out_chart)
134+
out_chart = new_chart
135+
136+
return out_chart
137+
138+
139+
def limit_chart(params, chart_dict):
140+
"""
141+
Optionally limit the size of the chart based on a query-string parameter.
142+
"""
143+
144+
# if a 'limit' query-string parameter is defined, "slice" the dictionary
145+
limit = util.param_limit_int(params)
146+
if limit > 0:
147+
# https://stackoverflow.com/a/66535220/1742875
148+
short_dict = dict(list(chart_dict.items())[:limit])
149+
return short_dict
150+
151+
return chart_dict
152+
153+
154+
def list_tags(params, chart_dict):
155+
"""
156+
Generate a list of valid AWS tags. Only active codes are listed.
157+
158+
The string format is `{Program Name} / {Program Code}`.
159+
160+
Returns
161+
A list of strings.
162+
"""
163+
164+
tags = []
165+
166+
# build tags from chart of accounts
167+
for code, name in chart_dict.items():
168+
# enforce AWS tags limitations
169+
# https://docs.aws.amazon.com/tag-editor/latest/userguide/best-practices-and-strats.html
170+
# max tag value length is 256, truncate
171+
# only enforce when listing tag values
172+
tag = f"{name[:245]} / {code[:6]}"
173+
tags.append(tag)
174+
175+
limit = util.param_limit_int(params)
176+
if limit > 0:
177+
LOG.info(f"limiting output to {limit} values")
178+
return tags[0:limit]
179+
else:
180+
return tags

mip_api/s3.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import json
2+
import boto3
3+
4+
s3_client = None
5+
6+
7+
def cache_read(bucket, path):
8+
"""
9+
Read MIP response from S3 cache object
10+
"""
11+
global s3_client
12+
if s3_client is None:
13+
s3_client = boto3.client("s3")
14+
15+
data = s3_client.get_object(Bucket=bucket, Key=path)
16+
return json.loads(data["Body"].read())
17+
18+
19+
def cache_write(data, bucket, path):
20+
"""
21+
Write MIP response to S3 cache object
22+
"""
23+
global s3_client
24+
if s3_client is None:
25+
s3_client = boto3.client("s3")
26+
27+
body = json.dumps(data)
28+
s3_client.put_object(Bucket=bucket, Key=path, Body=body)

mip_api/ssm.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import logging
2+
3+
import boto3
4+
5+
LOG = logging.getLogger(__name__)
6+
LOG.setLevel(logging.DEBUG)
7+
8+
# This is global so that it can be stubbed in test.
9+
# Because it's global, its value will be retained
10+
# in the lambda environment and re-used on warm runs.
11+
ssm_client = None
12+
13+
14+
def get_secrets(ssm_path):
15+
"""Collect secure parameters from SSM"""
16+
17+
# create boto client
18+
global ssm_client
19+
if ssm_client is None:
20+
ssm_client = boto3.client("ssm")
21+
22+
# object to return
23+
ssm_secrets = {}
24+
25+
# get secret parameters from ssm
26+
params = ssm_client.get_parameters_by_path(
27+
Path=ssm_path,
28+
Recursive=True,
29+
WithDecryption=True,
30+
)
31+
if "Parameters" in params:
32+
for p in params["Parameters"]:
33+
# strip leading path plus / char
34+
if len(p["Name"]) > len(ssm_path):
35+
name = p["Name"][len(ssm_path) + 1 :]
36+
else:
37+
name = p["Name"]
38+
ssm_secrets[name] = p["Value"]
39+
LOG.info(f"Loaded secret: {name}")
40+
else:
41+
LOG.error(f"Invalid response from SSM client")
42+
raise Exception
43+
44+
for reqkey in ["user", "pass"]:
45+
if reqkey not in ssm_secrets:
46+
raise Exception(f"Missing required secure parameter: {reqkey}")
47+
48+
return ssm_secrets

0 commit comments

Comments
 (0)