-
Notifications
You must be signed in to change notification settings - Fork 3
/
app_server.py
369 lines (315 loc) · 12.8 KB
/
app_server.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
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
"""Implement logic common to more than one of the Simplified applications."""
from nose.tools import set_trace
from psycopg2 import DatabaseError
import flask
import json
import os
import sys
import subprocess
from lxml import etree
from functools import wraps
from flask import url_for, make_response
from flask.ext.babel import lazy_gettext as _
from util.flask_util import problem
from util.problem_detail import ProblemDetail
import traceback
import logging
from opds import (
AcquisitionFeed,
LookupAcquisitionFeed,
)
from util.opds_writer import (
OPDSFeed,
OPDSMessage,
)
from sqlalchemy.orm.session import Session
from sqlalchemy.orm.exc import (
NoResultFound,
)
from model import (
get_one,
Complaint,
Identifier,
Patron,
)
from util.cdn import cdnify
from classifier import Classifier
from config import Configuration
from lane import (
Facets,
Pagination,
)
from problem_details import *
cdns = Configuration.cdns()
def cdn_url_for(*args, **kwargs):
base_url = url_for(*args, **kwargs)
return cdnify(base_url, cdns)
def load_lending_policy(policy):
if not policy:
logging.info("No lending policy.")
return {}
if isinstance(policy, basestring):
policy = json.loads(policy)
for external_type, p in policy.items():
if Patron.AUDIENCE_RESTRICTION_POLICY in p:
for audience in p[Patron.AUDIENCE_RESTRICTION_POLICY]:
if not audience in Classifier.AUDIENCES:
raise ValueError(
"Unrecognized audience in lending policy: %s" %
audience)
return policy
def feed_response(feed, acquisition=True, cache_for=AcquisitionFeed.FEED_CACHE_TIME):
if acquisition:
content_type = OPDSFeed.ACQUISITION_FEED_TYPE
else:
content_type = OPDSFeed.NAVIGATION_FEED_TYPE
return _make_response(feed, content_type, cache_for)
def entry_response(entry, cache_for=AcquisitionFeed.FEED_CACHE_TIME):
content_type = OPDSFeed.ENTRY_TYPE
return _make_response(entry, content_type, cache_for)
def _make_response(content, content_type, cache_for):
if isinstance(content, etree._Element):
content = etree.tostring(content)
elif not isinstance(content, basestring):
content = unicode(content)
if isinstance(cache_for, int):
# A CDN should hold on to the cached representation only half
# as long as the end-user.
client_cache = cache_for
cdn_cache = cache_for / 2
cache_control = "public, no-transform, max-age: %d, s-maxage: %d" % (
client_cache, cdn_cache)
else:
cache_control = "private, no-cache"
return make_response(content, 200, {"Content-Type": content_type,
"Cache-Control": cache_control})
def load_facets_from_request(config=Configuration):
"""Figure out which Facets object this request is asking for."""
arg = flask.request.args.get
g = Facets.ORDER_FACET_GROUP_NAME
order = arg(g, config.default_facet(g))
g = Facets.AVAILABILITY_FACET_GROUP_NAME
availability = arg(g, config.default_facet(g))
g = Facets.COLLECTION_FACET_GROUP_NAME
collection = arg(g, config.default_facet(g))
return load_facets(order, availability, collection, config)
def load_pagination_from_request(default_size=Pagination.DEFAULT_SIZE):
"""Figure out which Pagination object this request is asking for."""
arg = flask.request.args.get
size = arg('size', default_size)
offset = arg('after', 0)
return load_pagination(size, offset)
def load_facets(order, availability, collection, config=Configuration):
"""Turn user input into a Facets object."""
order_facets = config.enabled_facets(
Facets.ORDER_FACET_GROUP_NAME
)
if order and not order in order_facets:
return INVALID_INPUT.detailed(
_("I don't know how to order a feed by '%(order)s'", order=order),
400
)
availability_facets = config.enabled_facets(
Facets.AVAILABILITY_FACET_GROUP_NAME
)
if availability and not availability in availability_facets:
return INVALID_INPUT.detailed(
_("I don't understand the availability term '%(availability)s'", availability=availability),
400
)
collection_facets = config.enabled_facets(
Facets.COLLECTION_FACET_GROUP_NAME
)
if collection and not collection in collection_facets:
return INVALID_INPUT.detailed(
_("I don't understand which collection '%(collection)s' refers to.", collection=collection),
400
)
return Facets(
collection=collection, availability=availability, order=order
)
def load_pagination(size, offset):
"""Turn user input into a Pagination object."""
try:
size = int(size)
except ValueError:
return INVALID_INPUT.detailed(_("Invalid page size: %(size)s", size=size))
size = min(size, 100)
if offset:
try:
offset = int(offset)
except ValueError:
return INVALID_INPUT.detailed(_("Invalid offset: %(offset)s", offset=offset))
return Pagination(offset, size)
def returns_problem_detail(f):
@wraps(f)
def decorated(*args, **kwargs):
v = f(*args, **kwargs)
if isinstance(v, ProblemDetail):
return v.response
return v
return decorated
class ErrorHandler(object):
def __init__(self, app, debug):
self.app = app
self.debug = debug
def handle(self, exception):
if hasattr(self.app, 'manager') and hasattr(self.app.manager, '_db'):
# There is an active database session. Roll it back.
self.app.manager._db.rollback()
tb = traceback.format_exc()
if isinstance(exception, DatabaseError):
# The database session may have become tainted. For now
# the simplest thing to do is to kill the entire process
# and let uwsgi restart it.
logging.error(
"Database error: %s Treating as fatal to avoid holding on to a tainted session!",
exception, exc_info=exception
)
shutdown = flask.request.environ.get('werkzeug.server.shutdown')
if shutdown:
shutdown()
else:
sys.exit()
# By default, the error will be logged at log level ERROR.
log_method = logging.error
# Okay, it's not a database error. Turn it into a useful HTTP error
# response.
if hasattr(exception, 'as_problem_detail_document'):
# This exception can be turned directly into a problem
# detail document.
document = exception.as_problem_detail_document(self.debug)
if not self.debug:
document.debug_message = None
else:
if document.debug_message:
document.debug_message += "\n\n" + tb
else:
document.debug_message = tb
if document.status_code == 502:
# This is an error in integrating with some upstream
# service. It's a serious problem, but probably not
# indicative of a bug in our software. Log it at log level
# WARN.
log_method = logging.warn
response = make_response(document.response)
else:
# There's no way to turn this exception into a problem
# document. This is probably indicative of a bug in our
# software.
if self.debug:
body = tb
else:
body = _('An internal error occured')
response = make_response(unicode(body), 500, {"Content-Type": "text/plain"})
log_method("Exception in web app: %s", exception, exc_info=exception)
return response
class HeartbeatController(object):
def heartbeat(self):
return make_response("", 200, {"Content-Type": "application/json"})
class URNLookupController(object):
"""A generic controller that takes URNs as input and looks up their
OPDS entries.
"""
UNRECOGNIZED_IDENTIFIER = "This work is not in the collection."
WORK_NOT_PRESENTATION_READY = "Work created but not yet presentation-ready."
WORK_NOT_CREATED = "Identifier resolved but work not yet created."
def __init__(self, _db):
self._db = _db
self.works = []
self.precomposed_entries = []
self.unresolved_identifiers = []
def work_lookup(self, annotator, route_name='lookup',
urns=[], **process_urn_kwargs):
"""Generate an OPDS feed describing works identified by identifier."""
urns = flask.request.args.getlist('urn')
this_url = cdn_url_for(route_name, _external=True, urn=urns)
for urn in urns:
self.process_urn(urn, **process_urn_kwargs)
self.post_lookup_hook()
opds_feed = LookupAcquisitionFeed(
self._db, "Lookup results", this_url, self.works, annotator,
precomposed_entries=self.precomposed_entries,
)
return feed_response(opds_feed)
def permalink(self, urn, annotator, route_name='work'):
"""Look up a single identifier and generate an OPDS feed."""
this_url = cdn_url_for(route_name, _external=True, urn=urn)
self.process_urn(urn)
self.post_lookup_hook()
# A LookupAcquisitionFeed's .works is a list of (identifier,
# work) tuples, but an AcquisitionFeed's .works is just a
# list of works.
works = [work for (identifier, work) in self.works]
opds_feed = AcquisitionFeed(
self._db, urn, this_url, works, annotator,
precomposed_entries=self.precomposed_entries
)
return feed_response(opds_feed)
def process_urn(self, urn, **kwargs):
"""Turn a URN into a Work suitable for use in an OPDS feed.
"""
try:
identifier, is_new = Identifier.parse_urn(self._db, urn)
except ValueError, e:
identifier = None
if not identifier:
# Not a well-formed URN.
return self.add_message(urn, 400, INVALID_URN.detail)
if not identifier.licensed_through:
# The default URNLookupController cannot look up an
# Identifier that has no associated LicensePool.
return self.add_message(urn, 404, self.UNRECOGNIZED_IDENTIFIER)
# If we get to this point, there is a LicensePool for this
# identifier.
work = identifier.licensed_through.work
if not work:
# There is a LicensePool but no Work.
return self.add_message(urn, 202, self.WORK_NOT_CREATED)
if not work.presentation_ready:
# There is a work but it's not presentation ready.
return self.add_message(urn, 202, self.WORK_NOT_PRESENTATION_READY)
# The work is ready for use in an OPDS feed!
return self.add_work(identifier, work)
def add_work(self, identifier, work):
"""An identifier lookup succeeded in finding a Work."""
self.works.append((identifier, work))
def add_entry(self, entry):
"""An identifier lookup succeeded in creating an OPDS entry."""
self.precomposed_entries.append(entry)
def add_message(self, urn, status_code, message):
"""An identifier lookup resulted in the creation of a message."""
self.precomposed_entries.append(
OPDSMessage(urn, status_code, message)
)
def post_lookup_hook(self):
"""Run after looking up a number of Identifiers.
By default, does nothing.
"""
pass
class ComplaintController(object):
"""A controller to register complaints against objects."""
def register(self, license_pool, raw_data):
if license_pool is None:
return problem(None, 400, _("No license pool specified"))
_db = Session.object_session(license_pool)
try:
data = json.loads(raw_data)
except ValueError, e:
return problem(None, 400, _("Invalid problem detail document"))
type = data.get('type')
source = data.get('source')
detail = data.get('detail')
if not type:
return problem(None, 400, _("No problem type specified."))
if type not in Complaint.VALID_TYPES:
return problem(None, 400, _("Unrecognized problem type: %(type)s", type=type))
complaint = None
try:
complaint = Complaint.register(license_pool, type, source, detail)
_db.commit()
except ValueError, e:
return problem(
None, 400, _("Error registering complaint: %(error)s", error=str(e))
)
return make_response(unicode(_("Success")), 201, {"Content-Type": "text/plain"})