Skip to content

Commit 32c2b91

Browse files
committed
Implement an intitial server based on flask.
Do the core work necessary to have a first response served to a GET request and some basic wiring to allow submission of JSON via POST. CHECKPOINT: running coreapi server overrides? not yet sure why this was needed Implement a user creation endpoint in the server. Back a POST handler onto the refactored createuser functionality file which exposes a function that can be invoked programatically with a configuration. Split out the response data decoding chunk. axe depedency pn reworkings on userapi fixups - move closer to the server working without createuser changes Implement finding users by email address.
1 parent db3ae71 commit 32c2b91

File tree

9 files changed

+718
-1
lines changed

9 files changed

+718
-1
lines changed

mig/services/__init__.py

Whitespace-only changes.

mig/services/coreapi/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from mig.services.coreapi.server import ThreadedApiHttpServer, _create_and_expose_server

mig/services/coreapi/__main__.py

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from argparse import ArgumentError
2+
from getopt import getopt
3+
import sys
4+
5+
from mig.shared.conf import get_configuration_object
6+
from mig.services.coreapi.server import main as server_main
7+
8+
9+
def _getopt_opts_to_options(opts):
10+
options = {}
11+
for k, v in opts:
12+
options[k[1:]] = v
13+
return options
14+
15+
16+
def _required_argument_error(option, argument_name):
17+
raise ArgumentError(None, 'Missing required argument: %s %s' %
18+
(option, argument_name.upper()))
19+
20+
21+
if __name__ == '__main__':
22+
(opts, args) = getopt(sys.argv[1:], 'c:')
23+
opts_dict = _getopt_opts_to_options(opts)
24+
25+
if 'c' not in opts_dict:
26+
raise _required_argument_error('-c', 'config_file')
27+
28+
configuration = get_configuration_object(opts_dict['c'],
29+
skip_log=True, disable_auth_log=True)
30+
server_main(configuration)

mig/services/coreapi/server.py

+314
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
#!/usr/bin/python
2+
# -*- coding: utf-8 -*-
3+
#
4+
# --- BEGIN_HEADER ---
5+
#
6+
# mig/services/coreapi/server - coreapi service server internals
7+
# Copyright (C) 2003-2025 The MiG Project by the Science HPC Center at UCPH
8+
#
9+
# This file is part of MiG.
10+
#
11+
# MiG is free software: you can redistribute it and/or modify
12+
# it under the terms of the GNU General Public License as published by
13+
# the Free Software Foundation; either version 2 of the License, or
14+
# (at your option) any later version.
15+
#
16+
# MiG is distributed in the hope that it will be useful,
17+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
18+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19+
# GNU General Public License for more details.
20+
#
21+
# You should have received a copy of the GNU General Public License
22+
# along with this program; if not, write to the Free Software
23+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
24+
#
25+
# -- END_HEADER ---
26+
#
27+
28+
29+
"""HTTP server parts of the coreapi service."""
30+
31+
from __future__ import print_function
32+
from __future__ import absolute_import
33+
34+
from http.server import HTTPServer, BaseHTTPRequestHandler
35+
from socketserver import ThreadingMixIn
36+
37+
import base64
38+
from collections import defaultdict, namedtuple
39+
from flask import Flask, request, Response
40+
import os
41+
import sys
42+
import threading
43+
import time
44+
import werkzeug.exceptions as httpexceptions
45+
from wsgiref.simple_server import WSGIRequestHandler
46+
47+
from mig.shared.base import canonical_user, keyword_auto, force_native_str_rec
48+
from mig.shared.useradm import fill_user, \
49+
create_user as useradm_create_user, search_users as useradm_search_users
50+
from mig.shared.userdb import default_db_path
51+
52+
53+
httpexceptions_by_code = {
54+
exc.code: exc for exc in httpexceptions.__dict__.values() if hasattr(exc, 'code')}
55+
56+
57+
def http_error_from_status_code(http_status_code, http_url, description=None):
58+
return httpexceptions_by_code[http_status_code](description)
59+
60+
61+
class ValidationReport(RuntimeError):
62+
def __init__(self, errors_by_field):
63+
self.errors_by_field = errors_by_field
64+
65+
def serialize(self, output_format='text'):
66+
if output_format == 'json':
67+
return dict(errors=self.errors_by_field)
68+
else:
69+
lines = ["- %s: required %s" %
70+
(k, v) for k, v in self.errors_by_field.items()]
71+
lines.insert(0, '')
72+
return 'payload failed to validate:%s' % ('\n'.join(lines),)
73+
74+
75+
def _create_user(user_dict, conf_path, **kwargs):
76+
try:
77+
useradm_create_user(user_dict, conf_path, keyword_auto, **kwargs)
78+
except Exception as exc:
79+
return 1
80+
return 0
81+
82+
83+
def _define_payload(payload_name, payload_fields):
84+
class Payload(namedtuple('PostUserArgs', payload_fields)):
85+
def keys(self):
86+
return Payload._fields
87+
88+
def items(self):
89+
return self._asdict().items()
90+
91+
Payload.__name__ = payload_name
92+
93+
return Payload
94+
95+
96+
def _is_not_none(value):
97+
"""value is not None"""
98+
return value is not None
99+
100+
101+
def _is_string_and_non_empty(value):
102+
"""value is a non-empty string"""
103+
return isinstance(value, str) and len(value) > 0
104+
105+
106+
_REQUEST_ARGS_POST_USER = _define_payload('PostUserArgs', [
107+
'full_name',
108+
'organization',
109+
'state',
110+
'country',
111+
'email',
112+
'comment',
113+
'password',
114+
])
115+
116+
117+
_REQUEST_ARGS_POST_USER._validators = defaultdict(lambda: _is_not_none, dict(
118+
full_name=_is_string_and_non_empty,
119+
organization=_is_string_and_non_empty,
120+
state=_is_string_and_non_empty,
121+
country=_is_string_and_non_empty,
122+
email=_is_string_and_non_empty,
123+
comment=_is_string_and_non_empty,
124+
password=_is_string_and_non_empty,
125+
))
126+
127+
128+
def search_users(configuration, search_filter):
129+
_, hits = useradm_search_users(search_filter, configuration, keyword_auto)
130+
return list((obj for _, obj in hits))
131+
132+
133+
def validate_payload(definition, payload):
134+
args = definition(*[payload.get(field, None)
135+
for field in definition._fields])
136+
137+
errors_by_field = {}
138+
for field_name, field_value in args._asdict().items():
139+
validator_fn = definition._validators[field_name]
140+
if not validator_fn(field_value):
141+
errors_by_field[field_name] = validator_fn.__doc__
142+
if errors_by_field:
143+
raise ValidationReport(errors_by_field)
144+
else:
145+
return args
146+
147+
148+
def _create_and_expose_server(server, configuration):
149+
app = Flask('coreapi')
150+
151+
@app.get('/user')
152+
def GET_user():
153+
raise http_error_from_status_code(400, None)
154+
155+
@app.get('/user/<username>')
156+
def GET_user_username(username):
157+
return 'FOOBAR'
158+
159+
@app.get('/user/find')
160+
def GET_user_find():
161+
query_params = request.args
162+
163+
objects = search_users(configuration, {
164+
'email': query_params['email']
165+
})
166+
167+
if len(objects) != 1:
168+
raise http_error_from_status_code(404, None)
169+
170+
return dict(objects=objects)
171+
172+
@app.post('/user')
173+
def POST_user():
174+
payload = request.get_json()
175+
176+
try:
177+
validated = validate_payload(_REQUEST_ARGS_POST_USER, payload)
178+
except ValidationReport as vr:
179+
return http_error_from_status_code(400, None, vr.serialize())
180+
181+
user_dict = canonical_user(
182+
configuration, validated, _REQUEST_ARGS_POST_USER._fields)
183+
fill_user(user_dict)
184+
force_native_str_rec(user_dict)
185+
186+
ret = _create_user(user_dict, configuration, default_renew=True)
187+
if ret != 0:
188+
raise http_error_from_status_code(400, None)
189+
190+
greeting = 'hello client!'
191+
return Response(greeting, 201)
192+
193+
return app
194+
195+
196+
class ApiHttpServer(HTTPServer):
197+
"""
198+
http(s) server that contains a reference to an OpenID Server and
199+
knows its base URL.
200+
Extended to fork on requests to avoid one slow or broken login stalling
201+
the rest.
202+
"""
203+
204+
def __init__(self, configuration, logger=None, host=None, port=None, **kwargs):
205+
self.configuration = configuration
206+
self.logger = logger if logger else configuration.logger
207+
self.server_app = None
208+
self._on_start = kwargs.pop('on_start', lambda _: None)
209+
210+
addr = (host, port)
211+
HTTPServer.__init__(self, addr, ApiHttpRequestHandler, **kwargs)
212+
213+
@property
214+
def base_environ(self):
215+
return {}
216+
217+
def get_app(self):
218+
return self.server_app
219+
220+
def server_activate(self):
221+
HTTPServer.server_activate(self)
222+
self._on_start(self)
223+
224+
225+
class ThreadedApiHttpServer(ThreadingMixIn, ApiHttpServer):
226+
"""Multi-threaded version of the ApiHttpServer"""
227+
228+
@property
229+
def base_url(self):
230+
proto = 'http'
231+
return '%s://%s:%d/' % (proto, self.server_name, self.server_port)
232+
233+
234+
class ApiHttpRequestHandler(WSGIRequestHandler):
235+
"""TODO: docstring"""
236+
237+
def __init__(self, socket, addr, server, **kwargs):
238+
self.server = server
239+
240+
# NOTE: drop idle clients after N seconds to clean stale connections.
241+
# Does NOT include clients that connect and do nothing at all :-(
242+
self.timeout = 120
243+
244+
self._http_url = None
245+
self.parsed_uri = None
246+
self.path_parts = None
247+
self.retry_url = ''
248+
249+
WSGIRequestHandler.__init__(self, socket, addr, server, **kwargs)
250+
251+
@property
252+
def configuration(self):
253+
return self.server.configuration
254+
255+
@property
256+
def daemon_conf(self):
257+
return self.server.configuration.daemon_conf
258+
259+
@property
260+
def logger(self):
261+
return self.server.logger
262+
263+
264+
def start_service(configuration, host=None, port=None):
265+
assert host is not None, "required kwarg: host"
266+
assert port is not None, "required kwarg: port"
267+
268+
logger = configuration.logger
269+
270+
def _on_start(server, *args, **kwargs):
271+
server.server_app = _create_and_expose_server(
272+
None, server.configuration)
273+
274+
httpserver = ThreadedApiHttpServer(
275+
configuration, host=host, port=port, on_start=_on_start)
276+
277+
serve_msg = 'Server running at: %s' % httpserver.base_url
278+
logger.info(serve_msg)
279+
print(serve_msg)
280+
while True:
281+
logger.debug('handle next request')
282+
httpserver.handle_request()
283+
logger.debug('done handling request')
284+
httpserver.expire_volatile()
285+
286+
287+
def main(configuration=None):
288+
if not configuration:
289+
# Force no log init since we use separate logger
290+
configuration = get_configuration_object(skip_log=True)
291+
292+
logger = configuration.logger
293+
294+
# Allow e.g. logrotate to force log re-open after rotates
295+
register_hangup_handler(configuration)
296+
297+
# FIXME:
298+
host = 'localhost' # configuration.user_openid_address
299+
port = 5555 # configuration.user_openid_port
300+
server_address = (host, port)
301+
302+
info_msg = "Starting coreapi..."
303+
logger.info(info_msg)
304+
print(info_msg)
305+
306+
try:
307+
start_service(configuration, host=host, port=port)
308+
except KeyboardInterrupt:
309+
info_msg = "Received user interrupt"
310+
logger.info(info_msg)
311+
print(info_msg)
312+
info_msg = "Leaving with no more workers active"
313+
logger.info(info_msg)
314+
print(info_msg)

mig/shared/useradm.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -2265,7 +2265,9 @@ def search_users(search_filter, conf_path, db_path,
22652265
fnmatch for.
22662266
"""
22672267

2268-
if conf_path:
2268+
if isinstance(conf_path, Configuration):
2269+
configuration = conf_path
2270+
elif conf_path:
22692271
if isinstance(conf_path, basestring):
22702272
configuration = Configuration(conf_path)
22712273
else:

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# migrid core dependencies on a format suitable for pip install as described on
22
# https://pip.pypa.io/en/stable/reference/requirement-specifiers/
3+
flask
34
future
45
# NOTE: python-3.6 and earlier versions require older pyotp, whereas 3.7+
56
# should work with any modern version. We tested 2.9.0 to work.

0 commit comments

Comments
 (0)