概要

Bottleに限った話ではありませんが、RESTful APIサーバを実装したい、となった場合当然複数のファイルに分割したくなりますよね。
ということで、Bottleでファイルを分割する方法を調べたので纏めます。

RESTful APIで以下のURLでアクセスできるサーバの想定です。

URL 内容
http://localhost:8081/ トップページ
http://localhost:8081/resources resourcesのトップページ
http://localhost:8081/resources/users usersリソース(GET、PUT、POST、DELETE)
http://localhost:8081/resources/users/123 特定のusersリソース(GET)

ディレクトリ構成は以下のようになります。

Project/
|
| ---- main.py
| ---- settings.py
| ----resources/
          |
          | ---- resources.py
          | ---- users.py

main.py

from bottle import route, run, Bottle
from bottle import post, get, put, delete, request, response, route
import json

import books
from resources import resources

root: Bottle = Bottle()

@root.route('/')
def index():
    return {'message': 'Hello. This is top page.'}

if __name__ == '__main__':

    # mergeでも同様のことがデキる。こちらはprefixは付けられない。
    # さらに、booksの中でhookを指定していても、mergeで取り込まれている場合はhookは動作しない。
    #root.merge(books.app)

    # 以下のresources.py(resources/resources.py)で、さらに別のroutesを指定することも可能。
    # このようにワンクッション置くことで、/reousrcesと言うようなURLのみの場合にも何かメッセージを表示することが可能。
    root.mount('/resources', resources.app)

    root.run (host='localhost', port=8081, debug=True, reloader=True)

このmain.pyがアプリケーション(Bottle)を起動します。
Bottleでファイルを分割する、つまりMVCで言うところのControllerを複数ファイルに分割する方法ですが、上記のコードにある通り、mergemount関数を使うことで実現できます。

merge関数

これを使うと、第1引数で指定したPythonコード(Bottleアプリケーション)が単純に読み込まれます。
なので、アクセスするためのURLなどはその読み込んだBottleアプリケーション自体で指定しているものが利用されます。
非常にお手軽なのですが、アクセスするためのURLの指定を利用側(main.py)で指定できないので、main.pyで読み込む複数のBottleアプリケーションで同じURLを指定してしまうと期待した通りの動作にならない、という問題が有ります。
また、コメントにも有りますがmergeでBottleアプリケーションを取り込んだ場合、hook関数が動作しません。
そのため、基本的にはこのmergeは使わずに、以下のmountを利用することになります。

mount関数

mount関数では、第1引数にアクセスするためのURL、第2引数の取り込むBottleアプリケーションを指定します。
mergeと違い、URLをこちら(main.py)で指定できるので、Bottleアプリケーション側でURLの重複を気にする必要がなくなります。

resources/resources.py

from bottle import Bottle, route, get, response, request, redirect
from resources import users
import settings

app = Bottle()
app.add_hook('after_request', settings.enable_cors)

# For CORS
# https://github.com/axios/axios/issues/475
# http://blog.yaorenjie.com/2015/06/03/Enable-CORS-in-bottle-py/
# https://github.com/bottlepy/bottle/issues/402
@app.route('/', method='OPTIONS')
def for_cors():
    return {}

@app.get('/')
def index():
    return {'message': 'This is api top page.'}
# resources/でアクセスできるRESTful API群を以下に羅列していく
app.mount('/users', users.app)

このファイルだけを見れば、URLはドメイン/でアクセスできるようになっています。
しかし、このresources.pyを読み込んでいるmain.pyで、URLをresources/と指定しているので、ドメイン/resourcesというURLにアクセスした場合に、このresources.pyのindex関数が実行されます。
そしてさらに、このresources/配下で動作させたいRESTful APIを記述したBottleアプリケーションをココでもmountすればOKです。
ココでもmain.py同様にURLを指定できます。それが

app.mount('/users', users.app)

ですね。

なお、態々resources.pyでワンクッション挟まずに、main.pyの中で直接各Bottleアプリケーションをマウントすることも可能です。

# main.pyで直接URLも指定
root.mount('/resources/users/', users.app)
root.mount('/resources/books/', books.app)
root.mount('/resources/contents/', contents.app)

そこまでアプリケーションの規模が大きくないのであればコレでも全然OKだと思います。
ただある程度の規模になってくると管理も大変なので、個人的には最初からURLとディレクトリ構成/ファイル構成は一致させておいたが方が楽だと思います。

resources/users.py

from bottle import Bottle, get, response, request, hook
import settings

app: Bottle = Bottle()
app.add_hook('after_request', settings.enable_cors)

# add_hookじゃなくても以下のように指定することも出来る。
#@app.hook('after_request')
#def enable_cors():
#    settings.enable_cors()

# For CORS
# https://github.com/axios/axios/issues/475
# http://blog.yaorenjie.com/2015/06/03/Enable-CORS-in-bottle-py/
# https://github.com/bottlepy/bottle/issues/402
@app.route('/', method='OPTIONS')
def for_cors():
    return {}

@app.get('/')
def list():
    return {'message': 'users#get'}

@app.get('/<id:int>')
def index(id):
    return {'message': 'users#get({})'.format(id)}

@app.post('/')
def create():
    return {'message': 'users#create(post), {}'.format(request.json['name'])}

@app.put('/')
def update():
    return {'message': 'users#update(put)'}

@app.delete('/')
def delete():
    return {'message': 'users#delete(delete)'}

コレがRESTful APIを提供するusers.pyです。
それぞれの関数はHTTPメソッド毎に切り分けられるので、URLは当然同じものです。(/
そして、このusers.pyを読み込んでいるBottleアプリケーション(resources.py)では、このusers.pyへのURLをusers/と指定しているので、ドメイン/resources/users(IDありはドメイン/resources/users/123)でこのusersリソースにアクセスすることが出来ます。
とても自然なRESTful APIのURLが実現できました。

settings.py

RESTful APIサーバなので当然CORSを設定したいですね。
ということで、すでにusers.pyに記載が有りましたが、app.add_hook('after_request', settings.enable_cors)で実行している関数が以下のものになります。
単純にCORS用の情報をレスポンスヘッダに詰め込んでいるだけです。

from bottle import response

def enable_cors():
    print("settings.enable_cors")
    response.headers['Access-Control-Allow-Origin'] = '*'
    response.headers['Access-Control-Allow-Methods'] = 'PUT, GET, POST, DELETE, OPTIONS'
    response.headers['Access-Control-Allow-Headers'] = 'Origin, Accept, Content-Type, X-Requested-With, X-CSRF-Token'

注意点

mountで読みこめば、@app.hook('after_request')app.add_hook('after_request', settings.enable_cors)はそれぞれで必ずちゃんと動作するので、例えば特定URL配下(/resource/*)にアクセスした場合にログを出力する、といった処理はresources/resource.pyhookで記述すればOK。
だけど、Bottleが持つresponseオブジェクトの中身は、最後に実行されるBottleアプリケーションの中身で毎回初期化されているので、responseを弄るCORSなどは必ずそれぞれのBottleアプリケーションで実行しなければなりません。
今回の例ならusers.pyapp.add_hook('after_request', settings.enable_cors)をコメントアウトすると、/resources/usersでは、CORSヘッダーが付与されなくなります。
(resources.pyのhookでCORSをresponseヘッダに付与しているけど、その後のusers.pyでresponseヘッダが初期値に戻る)

また、axiosなどでJavaScriptからJSONリクエストを送信すると、先ずは必ずOPTIONSメソッドでCORS情報自体の取得が実行されます。
そのため、各Bottleアプリケーションで必ずOPTIONSに応答する処理を書いておく必要が有ります。(空のJSONを返すだけでOK)
恐らくmain.pyなどでまとめて記述することが出来るとは思うのですが、まだそこまでは確認できていません。