-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathapp.py
276 lines (222 loc) · 10.9 KB
/
app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
from typing import Annotated, ClassVar, Literal, List
from fastapi import FastAPI, Query
from pydantic import BaseModel, model_validator, conint, confloat
# local
from catalog import data_locations, data_formats, data_catalog
from util import get_metadata, check_for_data_and_package_it, mockup_message
#############################################################################################################
# APP
########################################################################################################################
# Here we set up the app and its metadata
# The metadata is used to generate the OpenAPI documentation
description = """
Query climate data from **Scenarios Network for Alaska and Arctic Planning** (SNAP) holdings
## Service Categories
You can get data about these topics:
* **Atmosphere**
* **Hydrosphere**
* **Biosphere**
* **Cryosphere**
* **Anthroposphere**
## Request Parameters
You can query data by:
* **Variable**
* **Time Range**
* **Geographic Location** _(by location ID or coordinates)_
"""
tags_metadata = [
{
"name": "about",
"description": "Get information about the API and its service categories.",
"externalDocs": {
"description": "Read about SNAP's API",
"url": "https://earthmaps.io/",
},
},
{
"name": "data",
"description": "Request data from SNAP's holdings.",
"externalDocs": {
"description": "Read tutorials on data access",
"url": "https://arcticdatascience.org/",
},
},
]
app = FastAPI(openapi_tags=tags_metadata)
app = FastAPI(
title="SNAP API",
summary=None,
description=description,
version="2.0",
terms_of_service=None,
contact={
"name": "SNAP Team",
"url": "https://uaf-snap.org/contact/",
"email": "[email protected]",
},
license_info={
"name": "Creative Commons",
"url": "https://creativecommons.org/licenses/by/4.0/",
},
)
############################################################################################################
# MODELS
############################################################################################################
### Pydantic Models:
# Here "model" is in the sense of pydantic models, not climate data models!
# The pydantic models below are used to validate the query parameters for each route.
# They are used to check for sane values for each parameter, and provide default values where appropriate.
# They are used to check that the request is well-formed and is within the general bounds of our holdings.
# The metadata in the models comes directly from a metadata catalog, which would be populated programmatically (TBD).
# Ideally, this app would not need to be updated when the metadata changes, as long as the structure remains the same.
### Parent models: AboutParameters and GeneralDataParameters
# These are general models that contain fields common to all "/about/" and "/data/" requests
# They are used to validate certain request parameters that will be inherited by the child models
### Child models: AtmosphereDataParameters, HydrosphereDataParameters, BiosphereDataParameters, CryosphereDataParameters, AnthroposphereDataParameters
# These are models that contain fields specific to each service category
# Child models inherit all fields from the parent models
### Class Variables:
# Here "variable" is in the sense of python variables, not climate data variables!
# These can be defined by ranges in the metadata catalog, or hardcoded into the app
# They get passed to the model fields to create choice lists for validation
### Model Fields:
# These are the actual fields used in the request GET parameters.
# We define the data types, if they are required/optional, and provide defaults if appropriate
# If using class variables choice lists defined by the metadata catalog,
# model fields can be responsive to changes in the metadata catalog without the app needing to be updated
class AboutParameters(BaseModel, extra="forbid"):
# Class Variables:
service_categories: ClassVar[list] = list(data_catalog["service_category"].keys())
# Model Fields:
service_category: Literal[tuple(service_categories)] | None = None
class GeneralDataParameters(BaseModel, extra="forbid"):
# Class Variables:
locations: ClassVar[dict] = data_locations
formats: ClassVar[dict] = data_formats
# Model Fields:
location: List[Literal[tuple(locations["all"])]] | None = locations["default"]
format: Literal[(tuple(formats["all"]))] = formats["default"]
lat: confloat(ge=-90, le=90) | None = None
lon: confloat(ge=-180, le=180) | None = None
# General validation functions (for fields that are in the parent model)
@model_validator(mode="after")
def validate_lat_lon(self):
if self.location is None and self.lat is None:
raise ValueError("Either location or both lat & lon must be provided.")
if self.location is None and self.lon is None:
raise ValueError("Either location or both lat & lon must be provided.")
if self.location is not None and (self.lat is not None or self.lon is not None):
raise ValueError(
"Can only request location or lat & lon, cannot request both."
)
return self
# Non-general validation functions (for fields that may be specific to child model)
# these functions need to check for the existence of the fields before running using hasattr()
@model_validator(mode="after")
def validate_years(cls, self):
if not hasattr(self, "start_year") or not hasattr(self, "end_year"):
return self
else:
if not self.start_year < self.end_year:
raise ValueError("Start year must be before end year.")
if not cls.first_year <= self.start_year <= cls.last_year:
raise ValueError(
f"Start year must be between {cls.first_year} and {cls.last_year}."
)
if not cls.first_year <= self.end_year <= cls.last_year:
raise ValueError(
f"End year must be between {cls.first_year} and {cls.last_year}."
)
return self
class AtmosphereDataParameters(GeneralDataParameters):
# Class Variables:
variables: ClassVar[list] = data_catalog["service_category"]["atmosphere"][
"variable"
]
# apply function to get metadata from the catalog using the service category and variable(s)
metadata: ClassVar[tuple] = get_metadata("atmosphere", variables, data_catalog)
first_year: ClassVar[int] = metadata["first_year"]
last_year: ClassVar[int] = metadata["last_year"]
# Model Fields:
variable: List[Literal[tuple(variables)]] = ...
start_year: conint(ge=first_year, le=last_year) = ...
end_year: conint(ge=first_year, le=last_year) = ...
class HydrosphereDataParameters(GeneralDataParameters):
# Class Variables:
variables: ClassVar[list] = data_catalog["service_category"]["hydrosphere"][
"variable"
]
metadata: ClassVar[tuple] = get_metadata("hydrosphere", variables, data_catalog)
first_year: ClassVar[int] = metadata["first_year"]
last_year: ClassVar[int] = metadata["last_year"]
# Model Fields:
variable: List[Literal[tuple(variables)]] = ...
start_year: conint(ge=first_year, le=last_year) = ...
end_year: conint(ge=first_year, le=last_year) = ...
class BiosphereDataParameters(GeneralDataParameters):
# Class Variables:
variables: ClassVar[list] = data_catalog["service_category"]["biosphere"][
"variable"
]
metadata: ClassVar[tuple] = get_metadata("biosphere", variables, data_catalog)
first_year: ClassVar[int] = metadata["first_year"]
last_year: ClassVar[int] = metadata["last_year"]
# Model Fields:
variable: List[Literal[tuple(variables)]] = ...
start_year: conint(ge=first_year, le=last_year) = ...
end_year: conint(ge=first_year, le=last_year) = ...
class CryosphereDataParameters(GeneralDataParameters):
# Class Variables:
variables: ClassVar[list] = data_catalog["service_category"]["cryosphere"][
"variable"
]
metadata: ClassVar[tuple] = get_metadata("cryosphere", variables, data_catalog)
first_year: ClassVar[int] = metadata["first_year"]
last_year: ClassVar[int] = metadata["last_year"]
# Model Fields:
variable: List[Literal[tuple(variables)]] = ...
start_year: conint(ge=first_year, le=last_year) = ...
end_year: conint(ge=first_year, le=last_year) = ...
class AnthroposphereDataParameters(GeneralDataParameters):
# Class Variables:
variables: ClassVar[list] = data_catalog["service_category"]["anthroposphere"][
"variable"
]
# Model Fields:
variable: List[Literal[tuple(variables)]] = ...
############################################################################################################
# ROUTES
############################################################################################################
# These routes will validate the request parameters against the ranges in the metadata catalog and return a mockup message.
# They would also fetch, package, and return any data that matches user-specified parameters (TBD).
@app.get("/about/", tags=["about"])
def root(parameters: Annotated[AboutParameters, Query()]):
"""
Returns a description of the API. Optionally, returns descriptions of service categories from user-specified parameters.
"""
return mockup_message(parameters, "about")
@app.get("/data/atmosphere/", tags=["data"])
def root(parameters: Annotated[AtmosphereDataParameters, Query()]):
packaged_data = check_for_data_and_package_it("atmosphere", parameters)
# return packaged_data # not implemented yet
return mockup_message(parameters, "atmosphere")
@app.get("/data/hydrosphere/", tags=["data"])
def root(parameters: Annotated[HydrosphereDataParameters, Query()]):
packaged_data = check_for_data_and_package_it("hydrosphere", parameters)
# return packaged_data # not implemented yet
return mockup_message(parameters, "hydrosphere")
@app.get("/data/biosphere/", tags=["data"])
def root(parameters: Annotated[BiosphereDataParameters, Query()]):
packaged_data = check_for_data_and_package_it("biosphere", parameters)
# return packaged_data # not implemented yet
return mockup_message(parameters, "biosphere")
@app.get("/data/cryosphere/", tags=["data"])
def root(parameters: Annotated[CryosphereDataParameters, Query()]):
packaged_data = check_for_data_and_package_it("cryosphere", parameters)
# return packaged_data # not implemented yet
return mockup_message(parameters, "cryosphere")
@app.get("/data/anthroposphere/", tags=["data"])
def root(parameters: Annotated[AnthroposphereDataParameters, Query()]):
packaged_data = check_for_data_and_package_it("anthroposphere", parameters)
# return packaged_data # not implemented yet
return mockup_message(parameters, "anthroposphere")