I was wondering if I could get some software design advice. Hypothetically, let's say that I'm writing an unofficial API for Reddit that lets you log into a Reddit account and manage it, manage messages, view account information, etc., with a wide array of features.
This is the typical pattern I use. All of the code is simplified and dumbed down.
class HTTPClient:
def __init__(self, session: requests.Session = None):
if not self.session:
self.session = requests.Session()
else:
self.session = session
def _request(self, method, url, **kwargs):
"""Internal request handler"""
resp = self.session.request(method, url, **kwargs)
if resp.ok:
return resp
raise Exception(resp, resp.text)
class RedditSession(HTTPClient):
"""Establishes a Reddit session and does the authentication stuff"""
def __init__(self, username:str, password: str, session: requests.Session = None):
super().__init__(session)
self.username = username
self.password = password
def _authentication_stuff_here(self):
self._request("get", "https://example.com")
pass
def _login(self):
pass
class RedditClient(RedditSession)
def __init__(self, username:str, password: str, session: requests.Session = None):
super().__init__(username, password, session)
self._login(self):
# Profile intance contains methods to manage profile info
self.profile = Profile(self.session)
# Manage account info (not profile info)
def get_account_information(self):
pass
def change_account_password(self, password: str):
pass
def change_account_email(self, email: str):
pass
I was wondering if anyone knew if this structure was considered bad design, and if anyone had any ideas on how I could improve it.
What particularly bothers me is the multiple layers of indirection; i.e., it feels repetitive to pass an optional session instance (or some other variable) through multiple parents. Also, things begin to feel even more messy when I pass the session to multiple other classes when using composition.
For example, what if I want to separate methods in a way that a user can type user.profile.message.delete()? Would it be normal practice to pass session to the Profile instance, which then passes it to the Message instance? Or is there a better way of achieving this without so many layers of abstraction?
I have tried various design patterns, but everything always feels "messy" in the end. Maybe it's due to my lack of OOP knowledge. Thank you in advance for any help/advice.
Related
I want to write some type of generic wrapper for api calls, that allows doing the requests without worrying about the token expiry, refreshes the token in the background.
Something like a context manager that handles token refresh in the background, invisible to the user. In order to do this, the "wrapper" must be able to re-run the code if a TokenExpiredException occured with the new Token.
For example, this code uses a 2 level try/except block, repeating the same call, and you have to pass api_call as string and duplicates code for calling the api:
def call_api_with_login(api_call: str, *args, **kwargs)
"""Call ``api_call`` method on the MyApi client with token error handling."""
def get_method(client: ApiClient, call: str):
"""Get method from a dot-separated string"""
return functools.reduce(getattr, call.split("."), client)
api = MyApi()
api_method = get_method(api.client, api_call)
try:
result = api_method(token, *args, **kwargs)
report_api_call()
except exceptions.TokenExpiredException as exc:
token = api.login().token
try:
result = api_method(token, *args, **kwargs)
except Exception as exc:
logging.exception(exc)
result = []
Besides the code duplication above and the fact that this "pattern" is quite limiting, it would be used like this:
call_api_with_login("books.list", author="Carl")
... which is kind of crappy, as we are passing method names as string, no access to code assistant, prone to errors, etc.
My initial idea is I would like to use something like a context manager to handle this, something like:
with authenticated_client as api_client, token:
api_client.books.list(token, author="xyz")
The context manager would yield the client and token? ... However, there is no way I can think of to replay the inner code in case of an exception and refresh token (unless I do a loop of sorts in the context manager, more like a generator, maybe?)
def authenticated_client():
api = MyApi()
token = cache_session.cache_get("API_TOKEN")
try:
yield api, token
except exceptions.TokenExpiredException as exc:
token = api.login().token
# ... how to rerun code?
Hope this example makes some sense without being fully descriptive of api client and all ...
Can someone recomend a better/cleaner way to do this, or maybe other ways to handle token refresh?
I tried the ideas explained above, the first works but is not really looking like good practice long term.
So what I mean by decorating is imagine you have
def api_call(token, *args, **kwargs):
... # some logic here
Your decorator will look something like this
def authorize_on_expire(func):
def wrapper(token, *args, **kwargs):
try:
result = func(token, *args, **kwargs)
except exceptions.TokenExpiredException as e:
token = ... # token refreshing logic
result = func(token, *args, **kwargs)
finally:
return result
return wrapper
and you just decorate your api_call(...) like so:
#authorize_on_expire
def api_call(token, *args, **kwargs):
... # some logic here
Context managers are created mostly for safely closing streams/connections/etc on error. One nice example that I have is rollback database transaction on any error and raise exception afterwards
I'm using telebot (https://github.com/eternnoir/pyTelegramBotAPI) to create a bot to send photos to its users. The point is I didn't see a way to restrict the access to this bot as I intend to share private images through this bot.
I read in this forum that through python-telegram-bot there is a way to limit the access from the sender's message (How To Limit Access To A Telegram Bot), but I didn't know if via pyTelegramBotAPI it is possible.
Do you know how can I solve it?
A bit late tot the party - perhaps for future post readers. You can wrap the function to disallow access.
An example below:
from functools import wraps
def is_known_username(username):
'''
Returns a boolean if the username is known in the user-list.
'''
known_usernames = ['username1', 'username2']
return username in known_usernames
def private_access():
"""
Restrict access to the command to users allowed by the is_known_username function.
"""
def deco_restrict(f):
#wraps(f)
def f_restrict(message, *args, **kwargs):
username = message.from_user.username
if is_known_username(username):
return f(message, *args, **kwargs)
else:
bot.reply_to(message, text='Who are you? Keep on walking...')
return f_restrict # true decorator
return deco_restrict
Then where you are handling commands you can restrict access to the command like this:
#bot.message_handler(commands=['start'])
#private_access()
def send_welcome(message):
bot.reply_to(message, "Hi and welcome")
Keep in mind, order matters. First the message-handler and then your custom decorator - or it will not work.
The easiest way is probably a hard coded check on the user id.
# The allowed user id
my_user_id = '12345678'
# Handle command
#bot.message_handler(commands=['picture'])
def send_picture(message):
# Get user id from message
to_check_id = message.message_id
if my_user_id = to_check_id:
response_message = 'Pretty picture'
else:
response_message = 'Sorry, this is a private bot!'
# Send response message
bot.reply_to(message, response_message)
I don't know how to exactly write a title for this, so let me explain my problem. I'm using Python 3 to implement a bot on Slack, but my doubt is about how classes can be used to solve it.
I'm using Slack's API to create a bot to send messages. When you use the API to send a message, it returns some parameters that indentifies this message (like an id). With this "id" I can change the message's text or delete it, for example.
I created a main class that will be used to handle the API requests and therefore, send messages. I also have another class called SlackMessage that keeps the message's "id" and will have some methods, like change it's text or delete itself, for example. So, when I send a message through the main class, an instance of SlackMessage class will be returned.
As the first class is handling the requests to the API, the SlackMessage class should use it when changing it's text. Would be right the SlackMessage class have the main class as reference when it's being created and use it to update itself?
Here's some code to explain what I mean:
class Slack:
def __init__(self, token):
self._token = token
def send_message(self, message):
result = requests.post(...)
#Get message's reference
ts = result["ts"]
channel = result["channel"]
text = result["text"]
reference = {"ts": ts, "channel": channel, "text": text}
return SlackMessage(self, reference)
def update_message(self, message_reference):
result = requests.post(...)
class SlackMessage:
def __init__(self, slack_client, reference):
self._slack_client = slack_client
self._reference = reference
def change_text(self, text):
self._reference["text"] = text
self._slack_client.update_message(self._reference)
And how would be the best method to create a way to delete the message? Should I do it the same way?
Sorry if this question is a duplicate, I searched for an answer but didn't know how to explain the problem with a few words.
Sorry for the noob question about classes. I'm trying to assign a soap client to a variable inside a class function and then access that variable in other class functions. I don't have any arguments to pass to the setup_client() function.
In the following example code, how do I make self.client accessible outside setup_client() so that I can use it in use_client(), and ditto making self.response available outside use_client()
class soap_call(self):
def __init__(self):
# What goes here?
self.client = # what?
self.response = # what?
def setup_client(self):
credentials = {'username': 'stuff', 'password': 'stuff'}
url = 'stuff'
t = HttpAuthenticated(**credentials)
self.client = suds.client.Client(url, transport=t)
def use_client(self):
self.response = self.client.service.whatever
print self.response
I quickly realized that if I add an optional client argument (self, client=None) to the class definition and include self.client = client, then I get a None type error when trying to use it in my functions.
I realize I just have a lack of understanding of classes. I've done some general reading up on classes but haven't come across any specific examples that describe what I'm dealing with.
I'd go with None in both cases because logically speaking, none of them exists at the time of object instantiation. It also allows you to do some sanity checking of your logic, e.g.
class SoapCall(object):
def __init__(self):
self.client = None
self.response = None
def setup_client(self):
credentials = {'username': 'stuff', 'password': 'stuff'}
url = 'stuff'
t = HttpAuthenticated(**credentials)
if self.client is None:
self.client = suds.client.Client(url, transport=t)
def use_client(self):
if self.client is None:
self.client = self.setup_client()
self.response = self.client.service.whatever
print self.response
It's fine to leave the client unspecified when you first create the instance, but then you need to be sure to call setup_client before you call use_client.
I have a CherryPy web application that requires authentication. I have working HTTP Basic Authentication with a configuration that looks like this:
app_config = {
'/' : {
'tools.sessions.on': True,
'tools.sessions.name': 'zknsrv',
'tools.auth_basic.on': True,
'tools.auth_basic.realm': 'zknsrv',
'tools.auth_basic.checkpassword': checkpassword,
}
}
HTTP auth works great at this point. For example, this will give me the successful login message that I defined inside AuthTest:
curl http://realuser:realpass#localhost/AuthTest/
Since sessions are on, I can save cookies and examine the one that CherryPy sets:
curl --cookie-jar cookie.jar http://realuser:realpass#localhost/AuthTest/
The cookie.jar file will end up looking like this:
# Netscape HTTP Cookie File
# http://curl.haxx.se/rfc/cookie_spec.html
# This file was generated by libcurl! Edit at your own risk.
localhost FALSE / FALSE 1348640978 zknsrv 821aaad0ba34fd51f77b2452c7ae3c182237deb3
However, I'll get an HTTP 401 Not Authorized failure if I provide this session ID without the username and password, like this:
curl --cookie 'zknsrv=821aaad0ba34fd51f77b2452c7ae3c182237deb3' http://localhost/AuthTest
What am I missing?
Thanks very much for any help.
So, the short answer is you can do this, but you have to write your own CherryPy tool (a before_handler), and you must not enable Basic Authentication in the CherryPy config (that is, you shouldn't do anything like tools.auth.on or tools.auth.basic... etc) - you have to handle HTTP Basic Authentication yourself. The reason for this is that the built-in Basic Authentication stuff is apparently pretty primitive. If you protect something by enabling Basic Authentication like I did above, it will do that authentication check before it checks the session, and your cookies will do nothing.
My solution, in prose
Fortunately, even though CherryPy doesn't have a way to do both built-in, you can still use its built-in session code. You still have to write your own code for handling the Basic Authentication part, but in total this is not so bad, and using the session code is a big win because writing a custom session manager is a good way to introduce security bugs into your webapp.
I ended up being able to take a lot of things from a page on the CherryPy wiki called Simple authentication and access restrictions helpers. That code uses CP sessions, but rather than Basic Auth it uses a special page with a login form that submits ?username=USERNAME&password=PASSWORD. What I did is basically nothing more than changing the provided check_auth function from using the special login page to using the HTTP auth headers.
In general, you need a function you can add as a CherryPy tool - specifically a before_handler. (In the original code, this function was called check_auth(), but I renamed it to protect().) This function first tries to see if the cookies contain a (valid) session ID, and if that fails, it tries to see if there is HTTP auth information in the headers.
You then need a way to require authentication for a given page; I do this with require(), plus some conditions, which are just callables that return True. In my case, those conditions are zkn_admin(), and user_is() functions; if you have more complex needs, you might want to also look at member_of(), any_of(), and all_of() from the original code.
If you do it like that, you already have a way to log in - you just submit a valid session cookie or HTTPBA credentials to any URL you protect with the #require() decorator. All you need now is a way to log out.
(The original code instead has an AuthController class which contains login() and logout(), and you can use the whole AuthController object in your HTTP document tree by just putting auth = AuthController() inside your CherryPy root class, and get to it with a URL of e.g. http://example.com/auth/login and http://example.com/auth/logout. My code doesn't use an authcontroller object, just a few functions.)
Some notes about my code
Caveat: Because I wrote my own parser for HTTP auth headers, it only parses what I told it about, which means just HTTP Basic Auth - not, for example, Digest Auth or anything else. For my application that's fine; for yours, it may not be.
It assumes a few functions defined elsewhere in my code: user_verify() and user_is_admin()
I also use a debugprint() function which only prints output when a DEBUG variable is set, and I've left these calls in for clarity.
You can call it cherrypy.tools.WHATEVER (see the last line); I called it zkauth based on the name of my app. Take care NOT to call it auth, or the name of any other built-in tool, though .
You then have to enable cherrypy.tools.WHATEVER in your CherryPy configuration.
As you can see by all the TODO: messages, this code is still in a state of flux and not 100% tested against edge cases - sorry about that! It will still give you enough of an idea to go on, though, I hope.
My solution, in code
import base64
import re
import cherrypy
SESSION_KEY = '_zkn_username'
def protect(*args, **kwargs):
debugprint("Inside protect()...")
authenticated = False
conditions = cherrypy.request.config.get('auth.require', None)
debugprint("conditions: {}".format(conditions))
if conditions is not None:
# A condition is just a callable that returns true or false
try:
# TODO: I'm not sure if this is actually checking for a valid session?
# or if just any data here would work?
this_session = cherrypy.session[SESSION_KEY]
# check if there is an active session
# sessions are turned on so we just have to know if there is
# something inside of cherrypy.session[SESSION_KEY]:
cherrypy.session.regenerate()
# I can't actually tell if I need to do this myself or what
email = cherrypy.request.login = cherrypy.session[SESSION_KEY]
authenticated = True
debugprint("Authenticated with session: {}, for user: {}".format(
this_session, email))
except KeyError:
# If the session isn't set, it either wasn't present or wasn't valid.
# Now check if the request includes HTTPBA?
# FFR The auth header looks like: "AUTHORIZATION: Basic <base64shit>"
# TODO: cherrypy has got to handle this for me, right?
authheader = cherrypy.request.headers.get('AUTHORIZATION')
debugprint("Authheader: {}".format(authheader))
if authheader:
#b64data = re.sub("Basic ", "", cherrypy.request.headers.get('AUTHORIZATION'))
# TODO: what happens if you get an auth header that doesn't use basic auth?
b64data = re.sub("Basic ", "", authheader)
decodeddata = base64.b64decode(b64data.encode("ASCII"))
# TODO: test how this handles ':' characters in username/passphrase.
email,passphrase = decodeddata.decode().split(":", 1)
if user_verify(email, passphrase):
cherrypy.session.regenerate()
# This line of code is discussed in doc/sessions-and-auth.markdown
cherrypy.session[SESSION_KEY] = cherrypy.request.login = email
authenticated = True
else:
debugprint ("Attempted to log in with HTTBA username {} but failed.".format(
email))
else:
debugprint ("Auth header was not present.")
except:
debugprint ("Client has no valid session and did not provide HTTPBA credentials.")
debugprint ("TODO: ensure that if I have a failure inside the 'except KeyError'"
+ " section above, it doesn't get to this section... I'd want to"
+ " show a different error message if that happened.")
if authenticated:
for condition in conditions:
if not condition():
debugprint ("Authentication succeeded but authorization failed.")
raise cherrypy.HTTPError("403 Forbidden")
else:
raise cherrypy.HTTPError("401 Unauthorized")
cherrypy.tools.zkauth = cherrypy.Tool('before_handler', protect)
def require(*conditions):
"""A decorator that appends conditions to the auth.require config
variable."""
def decorate(f):
if not hasattr(f, '_cp_config'):
f._cp_config = dict()
if 'auth.require' not in f._cp_config:
f._cp_config['auth.require'] = []
f._cp_config['auth.require'].extend(conditions)
return f
return decorate
#### CONDITIONS
#
# Conditions are callables that return True
# if the user fulfills the conditions they define, False otherwise
#
# They can access the current user as cherrypy.request.login
# TODO: test this function with cookies, I want to make sure that cherrypy.request.login is
# set properly so that this function can use it.
def zkn_admin():
return lambda: user_is_admin(cherrypy.request.login)
def user_is(reqd_email):
return lambda: reqd_email == cherrypy.request.login
#### END CONDITIONS
def logout():
email = cherrypy.session.get(SESSION_KEY, None)
cherrypy.session[SESSION_KEY] = cherrypy.request.login = None
return "Logout successful"
Now all you have to do is enable both builtin sessions and your own cherrypy.tools.WHATEVER in your CherryPy configuration. Again, take care not to enable cherrypy.tools.auth. My configuration ended up looking like this:
config_root = {
'/' : {
'tools.zkauth.on': True,
'tools.sessions.on': True,
'tools.sessions.name': 'zknsrv',
}
}