Skip to content

Commit f093e2f

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. relocate fixup carve out payloads and rework them to make use of the validation helper shut up flake complaints about things that have changed further fixup another
1 parent 3fc85c1 commit f093e2f

File tree

9 files changed

+653
-1
lines changed

9 files changed

+653
-1
lines changed

mig/lib/__init__.py

Whitespace-only changes.

mig/lib/coresvc/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from mig.lib.coresvc.server import ThreadedApiHttpServer, \
2+
_create_and_expose_server

mig/lib/coresvc/__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/lib/coresvc/server.py

+243
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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.lib.coresvc.payloads import PayloadException, \
48+
PAYLOAD_POST_USER as _REQUEST_ARGS_POST_USER
49+
from mig.shared.base import canonical_user, keyword_auto, force_native_str_rec
50+
from mig.shared.useradm import fill_user, \
51+
create_user as useradm_create_user, search_users as useradm_search_users
52+
from mig.shared.userdb import default_db_path
53+
54+
55+
httpexceptions_by_code = {
56+
exc.code: exc for exc in httpexceptions.__dict__.values() if hasattr(exc, 'code')}
57+
58+
59+
def http_error_from_status_code(http_status_code, http_url, description=None):
60+
return httpexceptions_by_code[http_status_code](description)
61+
62+
63+
def _create_user(user_dict, conf_path, **kwargs):
64+
try:
65+
useradm_create_user(user_dict, conf_path, keyword_auto, **kwargs)
66+
except Exception as exc:
67+
return 1
68+
return 0
69+
70+
71+
def search_users(configuration, search_filter):
72+
_, hits = useradm_search_users(search_filter, configuration, keyword_auto)
73+
return list((obj for _, obj in hits))
74+
75+
76+
def _create_and_expose_server(server, configuration):
77+
app = Flask('coreapi')
78+
79+
@app.get('/user')
80+
def GET_user():
81+
raise http_error_from_status_code(400, None)
82+
83+
@app.get('/user/<username>')
84+
def GET_user_username(username):
85+
return 'FOOBAR'
86+
87+
@app.get('/user/find')
88+
def GET_user_find():
89+
query_params = request.args
90+
91+
objects = search_users(configuration, {
92+
'email': query_params['email']
93+
})
94+
95+
if len(objects) != 1:
96+
raise http_error_from_status_code(404, None)
97+
98+
return dict(objects=objects)
99+
100+
@app.post('/user')
101+
def POST_user():
102+
payload = request.get_json()
103+
104+
try:
105+
validated = _REQUEST_ARGS_POST_USER.ensure(payload)
106+
except PayloadException as vr:
107+
return http_error_from_status_code(400, None, vr.serialize())
108+
109+
user_dict = canonical_user(
110+
configuration, validated, _REQUEST_ARGS_POST_USER._fields)
111+
fill_user(user_dict)
112+
force_native_str_rec(user_dict)
113+
114+
ret = _create_user(user_dict, configuration, default_renew=True)
115+
if ret != 0:
116+
raise http_error_from_status_code(400, None)
117+
118+
greeting = 'hello client!'
119+
return Response(greeting, 201)
120+
121+
return app
122+
123+
124+
class ApiHttpServer(HTTPServer):
125+
"""
126+
http(s) server that contains a reference to an OpenID Server and
127+
knows its base URL.
128+
Extended to fork on requests to avoid one slow or broken login stalling
129+
the rest.
130+
"""
131+
132+
def __init__(self, configuration, logger=None, host=None, port=None, **kwargs):
133+
self.configuration = configuration
134+
self.logger = logger if logger else configuration.logger
135+
self.server_app = None
136+
self._on_start = kwargs.pop('on_start', lambda _: None)
137+
138+
addr = (host, port)
139+
HTTPServer.__init__(self, addr, ApiHttpRequestHandler, **kwargs)
140+
141+
@property
142+
def base_environ(self):
143+
return {}
144+
145+
def get_app(self):
146+
return self.server_app
147+
148+
def server_activate(self):
149+
HTTPServer.server_activate(self)
150+
self._on_start(self)
151+
152+
153+
class ThreadedApiHttpServer(ThreadingMixIn, ApiHttpServer):
154+
"""Multi-threaded version of the ApiHttpServer"""
155+
156+
@property
157+
def base_url(self):
158+
proto = 'http'
159+
return '%s://%s:%d/' % (proto, self.server_name, self.server_port)
160+
161+
162+
class ApiHttpRequestHandler(WSGIRequestHandler):
163+
"""TODO: docstring"""
164+
165+
def __init__(self, socket, addr, server, **kwargs):
166+
self.server = server
167+
168+
# NOTE: drop idle clients after N seconds to clean stale connections.
169+
# Does NOT include clients that connect and do nothing at all :-(
170+
self.timeout = 120
171+
172+
self._http_url = None
173+
self.parsed_uri = None
174+
self.path_parts = None
175+
self.retry_url = ''
176+
177+
WSGIRequestHandler.__init__(self, socket, addr, server, **kwargs)
178+
179+
@property
180+
def configuration(self):
181+
return self.server.configuration
182+
183+
@property
184+
def daemon_conf(self):
185+
return self.server.configuration.daemon_conf
186+
187+
@property
188+
def logger(self):
189+
return self.server.logger
190+
191+
192+
def start_service(configuration, host=None, port=None):
193+
assert host is not None, "required kwarg: host"
194+
assert port is not None, "required kwarg: port"
195+
196+
logger = configuration.logger
197+
198+
def _on_start(server, *args, **kwargs):
199+
server.server_app = _create_and_expose_server(
200+
None, server.configuration)
201+
202+
httpserver = ThreadedApiHttpServer(
203+
configuration, host=host, port=port, on_start=_on_start)
204+
205+
serve_msg = 'Server running at: %s' % httpserver.base_url
206+
logger.info(serve_msg)
207+
print(serve_msg)
208+
while True:
209+
logger.debug('handle next request')
210+
httpserver.handle_request()
211+
logger.debug('done handling request')
212+
httpserver.expire_volatile()
213+
214+
215+
def main(configuration=None):
216+
if not configuration:
217+
from mig.shared.conf import get_configuration_object
218+
# Force no log init since we use separate logger
219+
configuration = get_configuration_object(skip_log=True)
220+
221+
logger = configuration.logger
222+
223+
# Allow e.g. logrotate to force log re-open after rotates
224+
#register_hangup_handler(configuration)
225+
226+
# FIXME:
227+
host = 'localhost' # configuration.user_openid_address
228+
port = 5555 # configuration.user_openid_port
229+
server_address = (host, port)
230+
231+
info_msg = "Starting coreapi..."
232+
logger.info(info_msg)
233+
print(info_msg)
234+
235+
try:
236+
start_service(configuration, host=host, port=port)
237+
except KeyboardInterrupt:
238+
info_msg = "Received user interrupt"
239+
logger.info(info_msg)
240+
print(info_msg)
241+
info_msg = "Leaving with no more workers active"
242+
logger.info(info_msg)
243+
print(info_msg)

mig/shared/useradm.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -2318,7 +2318,9 @@ def search_users(search_filter, conf_path, db_path,
23182318
fnmatch for.
23192319
"""
23202320

2321-
if conf_path:
2321+
if isinstance(conf_path, Configuration):
2322+
configuration = conf_path
2323+
elif conf_path:
23222324
if isinstance(conf_path, basestring):
23232325
configuration = Configuration(conf_path)
23242326
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

56
# cgi was removed from the standard library in Python 3.13

tests/support/httpsupp.py

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import codecs
2+
import json
3+
4+
from tests.support._env import PY2
5+
6+
if PY2:
7+
from urllib2 import HTTPError, Request, urlopen
8+
from urllib import urlencode
9+
else:
10+
from urllib.error import HTTPError
11+
from urllib.parse import urlencode
12+
from urllib.request import urlopen, Request
13+
14+
15+
def attempt_to_decode_response_data(data, response_encoding=None):
16+
if data is None:
17+
return None
18+
elif response_encoding == 'textual':
19+
data = codecs.decode(data, 'utf8')
20+
21+
try:
22+
return json.loads(data)
23+
except Exception as e:
24+
return data
25+
elif response_encoding == 'binary':
26+
return data
27+
else:
28+
raise AssertionError(
29+
'issue_POST: unknown response_encoding "%s"' % (response_encoding,))
30+
31+
32+
class HttpAssertMixin:
33+
34+
def _issue_GET(self, server_address, request_path, query_dict=None, response_encoding='textual'):
35+
assert isinstance(server_address, tuple) and len(
36+
server_address) == 2, "require server address tuple"
37+
assert isinstance(request_path, str) and request_path.startswith(
38+
'/'), "require http path starting with /"
39+
request_url = ''.join(
40+
('http://', server_address[0], ':', str(server_address[1]), request_path))
41+
42+
if query_dict is not None:
43+
query_string = urlencode(query_dict)
44+
request_url = ''.join((request_url, '?', query_string))
45+
46+
status = 0
47+
data = None
48+
49+
try:
50+
response = urlopen(request_url, None, timeout=2000)
51+
52+
status = response.getcode()
53+
data = response.read()
54+
except HTTPError as httpexc:
55+
status = httpexc.code
56+
data = None
57+
58+
content = attempt_to_decode_response_data(data, response_encoding)
59+
return (status, content)
60+
61+
def _issue_POST(self, server_address, request_path, request_data=None, request_json=None, response_encoding='textual'):
62+
assert isinstance(server_address, tuple) and len(
63+
server_address) == 2, "require server address tuple"
64+
assert isinstance(request_path, str) and request_path.startswith(
65+
'/'), "require http path starting with /"
66+
request_url = ''.join(
67+
('http://', server_address[0], ':', str(server_address[1]), request_path))
68+
69+
if request_data and request_json:
70+
raise ValueError(
71+
"only one of data or json request data may be specified")
72+
73+
status = 0
74+
data = None
75+
76+
try:
77+
if request_json is not None:
78+
request_data = codecs.encode(json.dumps(request_json), 'utf8')
79+
request_headers = {
80+
'Content-Type': 'application/json'
81+
}
82+
request = Request(request_url, request_data,
83+
headers=request_headers)
84+
elif request_data is not None:
85+
request = Request(request_url, request_data)
86+
else:
87+
request = Request(request_url)
88+
89+
response = urlopen(request, timeout=2000)
90+
91+
status = response.getcode()
92+
data = response.read()
93+
except HTTPError as httpexc:
94+
status = httpexc.code
95+
data = httpexc.file.read()
96+
97+
content = attempt_to_decode_response_data(data, response_encoding)
98+
return (status, content)

0 commit comments

Comments
 (0)