Flask + HyperappでTodoアプリを作ってみた

こんにちは、あんはるです。

# Flask + HyperappでTodoアプリを作りました



# Flaskとは？
Python製の軽量Webアプリケーションフレームワーク。RubyでいうSinatraみたいなもの。



# Hyperappとは？
1 KBという超軽量のフロントエンドのフレームワーク。
とてもシンプルなのですぐに理解することができ、使いやすい。
QiitaのフロントエンドにHyperappが採用されたことから話題になる。



# なぜ、Flask + Hyperappか。


Flaskは機械学習モデルをWebAPIにするのによく使われています。
今、機械学習もやっていてプロトタイプとして機械学習モデルをWebAPIにしてみようと思っているので、
Flaskを使う練習としてFlaskを使おうと思いました。



Hyperappは、HyperappでWebAPIからデータを取得したりする処理をしてみたかったので、Hyperappにしました。(普通にHyperappが好き)


# こんな感じのTodoアプリを作った
![todo_gif.gif](https://qiita-image-store.s3.amazonaws.com/0/188304/4b1a8d21-4538-1a5f-000b-257471d0ff7c.gif)



データベースと繋がっているので、ローディングしても、Todoのデータ、完了か未完了かは保持されます。
![todo_gif_lo.gif](https://qiita-image-store.s3.amazonaws.com/0/188304/b1bdfca1-3f91-5001-fa2a-2879693f24e4.gif)



Github:
https://github.com/anharu2394/flask-hyperapp-todo_app
## TodoアプリAPIの実装(バックエンド)

### SQLAlchemyというORMでモデルを作る

```python:db.py
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy(api)
class Todo(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    value = db.Column(db.String(20), unique=True)
    completed = db.Column(db.Boolean)

    def __init__(self,value,completed):
        self.value = value
        self.completed = completed

    def __repr__(self):
        return '<Todo ' + str(self.id) + ':' + self.value + '>'
```

### APIはFlaskで。


```python:main.py
import json
from flask import Flask, jsonify, request, url_for, abort, Response,render_template
from db import db


api = Flask(__name__)
api.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db'
def createTodo(value):
    create_todo = Todo(value,False)
    db.session.add(create_todo) 
    try: 
        db.session.commit()
        return create_todo
    except:  
        print("this todo is already registered todo.")
        return {"error": "this todo is already registered todo."}

def deleteTodo(todo_id):
    try:
        todo = db.session.query(Todo).filter_by(id=todo_id).first()
        db.session.delete(todo)
        db.session.commit()
        return todo
    except:
        db.session.rollback()
        print("failed to delete this todo.")
        return {"error": "failed to delete this todo."}

def updateTodo(todo_id,completed):
    try:
        todo = db.session.query(Todo).filter_by(id=todo_id).first()
        todo.completed = completed
        db.session.add(todo)
        db.session.commit()
        return todo
    except:
        db.session.rollback()
        print("failed to update this todo.")
        return {"error": "failed to update this todo."}

def getTodo():
    return Todo.query.all()

# 一覧表示
try:
    print(Todo.query.all())
except:
    print("中身がない")

@api.route('/')
def index():
    return render_template("index.html")

@api.route('/api')
def api_index():
            return jsonify({'message': "This is the Todo api by Anharu."})

@api.route('/api/todos', methods=['GET'])
def todos():
    todos = []
    for todo in getTodo():
        todo = {"id": todo.id, "value": todo.value,"completed": todo.completed}
        todos.append(todo)

    return jsonify({"todos":todos})

@api.route('/api/todos', methods=['POST'])
def create():
    value = request.form["value"]
    create_todo = createTodo(value)
    if isinstance(create_todo,dict):
        return jsonify({"error": create_todo["error"]})
    else:
        return jsonify({"created_todo": create_todo.value})

@api.route('/api/todos/<int:todo_id>',methods=['PUT'])
def update_completed(todo_id):
    if request.form["completed"] == "true":
        completed = True
    else:
        completed = False
    print(completed)
    update_todo = updateTodo(todo_id,completed)
    if isinstance(update_todo,dict):
        return jsonify({"error": update_todo["error"]})
    else:
        return jsonify({"updated_todo": update_todo.value})

@api.route('/api/todos/<int:todo_id>', methods=['DELETE'])
def delete(todo_id):
    delete_todo = deleteTodo(todo_id)
    if isinstance(delete_todo,dict):
        return jsonify({"error": delete_todo["error"]})
    else:
        return jsonify({"deleted_todo": delete_todo.value})

@api.errorhandler(404)
def not_found(error):
    return jsonify({'error': 'Not found'})
if __name__ == '__main__':
    api.run(host='0.0.0.0', port=3333)
```



### サーバー起動

```
python main.py
```


getTodo(全Todo取得)、createTodo(Todoを追加する)、updateTodo(Todoを編集する)、deleteTodo(Todoを消す)という４つの関数を作り、
ルーティングを指定して、各関数を実行し、それの結果をjsonで返すように実装します。
APIはこのような感じです。



| path|HTTP method|目的|
|:-----------------|------------------:|:------------------:|
|/api|GET|なし|
| /api/todos|GET|全Todoの一覧を返す|
|/api/todos|POST|Todoを追加する|
|/api/todos/:id|PUT|Todoを編集する|
|/api/todos/:id|DELETE|Todoを消す|



```json:/api/todosのレスポンス例
{
  "todos": [
    {
      "completed": false,
      "id": 1,
      "value": "todo1"
    },
    {
      "completed": false,
      "id": 2,
      "value": "todo2"
    },
    {
      "completed": false,
      "id": 3,
      "value": "todo3"
    },
    {
      "completed": false,
      "id": 4,
      "value": "todo4"
    },
    {
      "completed": false,
      "id": 5,
      "value": "todo5"
    }
  ]
}
```



## フロントエンドの実装



```:ディレクトリ構成
todo_app
   ├-- main.py
   ├-- index.js
   ├-- index.css
   ├── node_modules
   ├── static
   ├── templates
   |      └── index.html
   ├── package.json
   ├── webpack.config.js
   └── yarn.lock
```

---

### 必要なパッケージの追加

```
yarn init -y
```

```
yarn add hyperapp
```
```
yarn add webpack webpack-cli css-loader style-loader babel-loader babel-core　babel-preset-env babel-preset-react babel-preset-es2015 babel-plugin-transform-react-jsx -D
```
### babelの設定

```json:.babelrc
{
  "presets": ["es2015"],
  "plugins": [
    [
      "transform-react-jsx",
      {
        "pragma": "h"
      }
    ]
  ]
}
```

### webpackの設定

```js:webpack.config.js
module.exports = {
  mode: 'development',
  entry: "./index.js",
  output: {
    filename: "bundle.js",
    path: __dirname + "/static"     
  },
  module: {
      rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: [
                ['env', {'modules': false}]
              ]
            }
          }
        ]
      },
      {
        test: /\.css$/,
        loaders: ['style-loader', 'css-loader?modules'],
      }
    ]
  }
 
}
```

これで環境は整った。



### メインのフロントを書いているindex.js
コードがごちゃごちゃしててすみません、、、

```jsx:index.js
import { h, app } from "hyperapp"
import axios from "axios"
import styles from "./index.css"

const state = {
    todoValue: "",
    todos: [],
    is_got: false
}

const actions = {
    getTodo:  () => (state,actions) => {
        axios.get("/api/todos").then(res => {
            console.log(res.data)
            actions.setTodo(res.data.todos)
        })
    },
    setTodo: data => state => ({todos: data}),
    addTodo: todoValue => (state,actions) => {
        console.log(todoValue)
        var params = new URLSearchParams()
        params.append("value",todoValue)
        axios.post("/api/todos",params).then(resp => {
            console.log(resp.data)
         }).catch(error=>{
            console.log(error)
        }
        )
        actions.todoEnd()
        actions.getTodo()
    },
    onInput: value => state => {
        state.todoValue = value
    },
    deleteTodo: id => (state,actions) => {
        console.log(id)
        axios.delete("/api/todos/" + id).then(resp => {
            console.log(resp.data)
        }).catch(error => {
            console.log(error)
        })
        actions.getTodo()
    },
    checkTodo: e => {
        console.log(e)
        console.log(e.path[1].id)
        const id = e.path[1].id
        console.log("/api/todos/" + id)
        var params = new URLSearchParams()
        params.append("completed",e.target.checked)
        axios.put("/api/todos/" + id,params).then(resp => {
            console.log(resp.data)
        }).catch(error => {
            console.log(error)
        })

        if (e.target.checked == true){
            document.getElementById(id).style.opacity ="0.5"
            document.getElementById("button_" + id).style.display = "inline"
        }
        else{
            document.getElementById(id).style.opacity ="1"
            document.getElementById("button_" + id).style.display = "none" 
        }
    },
    todoEnd: () => state => ({todoValue:""})
}

const Todos = () => (state, actions) => (
    <div class={styles.todos}>
        <h1>Todoリスト</h1>
        <h2>Todoを追加</h2>
        <input type="text" value={state.todoValue} oninput={e => actions.onInput(e.target.value)} onkeydown={e => e.keyCode === 13 ? actions.addTodo(e.target.value) : '' }  />
        <p>{state.todos.length}個のTodo</p>
        <ul>
        {
         state.todos.map((todo) => {
            if (todo.completed){
                return (
                        <li class={styles.checked} id={ todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)} />{todo.value}<button class={styles.checked}id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
            else{
                return (
                        <li id={todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)}/>{todo.value}<button id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
        })
        }
        </ul>
    </div>
)
const view = (state, actions) => {
    if (state.is_got == false){
        actions.getTodo()
        actions.todoGot()
    }
    return (<Todos />) 
}

app(state, actions, view, document.body)
```

CSS

```css:index.css
body {
}
.todos {
    margin:auto;
}
ul{
  padding: 0;
  position: relative;
  width: 50%;
}

ul li {
  color: black;
  border-left: solid 8px orange;
  background: whitesmoke;
  margin-bottom: 5px;
  line-height: 1.5;
  border-radius: 0 15px 15px 0;
  padding: 0.5em;
  list-style-type: none!important;
}
li.checked {
    opacity: 0.5;
}
button {
    display: none;
}
button.checked {
    display: inline;
}
```
HTML

```html:templates/index.html
<html>
  <head>
    <meta charset="utf-8">
    <title>The Todo App with Flask and Hyperapp</title>
  </head>
  <body>
    <script src="/static/bundle.js"></script>
  </body>
</html>
```



### webpackでビルドして、サーバー起動

```
yarn run webpack; python main.py
```

###　機能の仕組みの解説

#### Todo一覧を表示する機能
```jsx:Todosコンポーネント
const Todos = () => (state, actions) => (
    <div class={styles.todos}>
        <h1>Todoリスト</h1>
        <h2>Todoを追加</h2>
        <input type="text" value={state.todoValue} oninput={e => actions.onInput(e.target.value)} onkeydown={e => e.keyCode === 13 ? actions.addTodo(e.target.value) : '' }  />
        <p>{state.todos.length}個のTodo</p>
        <ul>
        {
         state.todos.map((todo) => {
            if (todo.completed){
                return (
                        <li class={styles.checked} id={ todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)} />{todo.value}<button class={styles.checked}id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
            else{
                return (
                        <li id={todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)}/>{todo.value}<button id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
        })
        }
        </ul>
    </div>
)

const view = (state, actions) => {
    if (state.is_got == false){
        actions.getTodo()
        actions.todoGot()
    }
    return (<Todos />) 
}
```
```js:stateたち
const state = {
    todoValue: "",
    todos: [],
    is_got: false
}
```
```js:action.getTodo()とaction.todoGot()
const actions = {
    getTodo:  () => (state,actions) => {
        axios.get("/api/todos").then(res => {
            console.log(res.data)
            actions.setTodo(res.data.todos)
        }).catch(error => {
            console.log(error)
        })
    },
    setTodo: data => state => ({todos: data}),
    todoGot: () => state => ({is_got:true})
}

```
actions.getTodo()を実行して、state.todosをセットし、その後Todosコンポーネントで表示します。
actions.getTodo()はaxiosでAPIにGETしていますが、fetchでもできます。

---

view　の部分を

```js
if (state.is_got == false){
    actions.getTodo()
    actions.todoGot()
}
```
こうしてるのは、そのまま、
```
actions.getTodo()
```
とすると、Stateが変更されるアクションなので、再レンダリングされ、また、actions.getTodo()が実行され、っと、無限に再レンダリングされてしまうので、is_gotというstateを作って、一回しか実行されないようにします。



####  Todoを追加する機能

```html
<input type="text" value={state.todoValue} oninput={e => actions.onInput(e.target.value)} onkeydown={e => e.keyCode === 13 ? actions.addTodo(e.target.value) : '' }  />
```
```js:stete
const state = {
    todoValue: ""
}
```
```oninput={e => actions.onInput(e.target.value)}```で、入力するやいなや、actions.onInputを実行させ、state.todoValueを更新しています。


```js:actions.onInput
const actions = {
    onInput: value => state => {
        state.todoValue = value
    }
}
```



```onkeydown={e => e.keyCode === 13 ? actions.addTodo(e.target.value) : '' } ```
Enterキーを押した時(Keyコードが13)に、actions.addTodo()を実行します。



```js
const actions = {
    getTodo:  () => (state,actions) => {
        axios.get("/api/todos").then(res => {
            console.log(res.data)
            actions.setTodo(res.data.todos)
        })
    },
    addTodo: todoValue => (state,actions) => {
        console.log(todoValue)
        var params = new URLSearchParams()
        params.append("value",todoValue)
        axios.post("/api/todos",params).then(resp => {
            console.log(resp.data)
         }).catch(error=>{
            console.log(error)
        }
        )
        actions.todoEnd()
        actions.getTodo()
    },
    todoEnd: () => state => ({todoValue:""})
}
```
actions.addTodo()では、```/api/todos```にPOSTし、新しいTodoを作ります。
actions.todoEnd()でstate.todoValueを空白にさせ次のTodoを入力しやすいようにします。
actions.getTodo()を実行させ、追加されたTodoも取得し表示させます。



#### Todoの完了未完了を設定する機能




```html
<input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)} />
```
チェックボックスをチェックした時(clickした時、)にactions.checkTodo()を実行します。
eは、elementの略で、その時の要素のオブジェクトを返します。



```js:actions.checkTodo()
const actions = {
    checkTodo: e => {
        console.log(e)
        console.log(e.path[1].id)
        const id = e.path[1].id
        console.log("/api/todos/" + id)
        var params = new URLSearchParams()
        params.append("completed",e.target.checked)
        axios.put("/api/todos/" + id,params).then(resp => {
            console.log(resp.data)
        }).catch(error => {
            console.log(error)
        })

        if (e.target.checked == true){
            document.getElementById(id).style.opacity ="0.5"
            document.getElementById("button_" + id).style.display = "inline"
        }
        else{
            document.getElementById(id).style.opacity ="1"
            document.getElementById("button_" + id).style.display = "none" 
        }
    }
}
```
e.path[1].idから、チェックされたTodoを見つけ、e.target.checkedで、完了か未完了かを、取得し、```/api/todos/1(id)```　へPUTします。

その後、完了のtodoは濃さを薄くし消去ボタンを表示させ、未完了のtodoは濃さを正常にして、消去ボタンを見えなくします。




```jsx:Todoコンポーネントの一部分
        <ul>
        {
         state.todos.map((todo) => {
            if (todo.completed){
                return (
                        <li class={styles.checked} id={ todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)} />{todo.value}<button class={styles.checked}id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
            else{
                return (
                        <li id={todo.id}><input type="checkbox" checked={todo.completed} onclick={e => actions.checkTodo(e)}/>{todo.value}<button id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button></li>
                )
            }
        })
        }
        </ul>
```
ローディングしてもそのままの状態を保持するため、完了か未完了かで条件分岐しています。



#### Todoを消す機能



```html
<button id={"button_" + todo.id} onclick={() => actions.deleteTodo(todo.id)}>消去</button>
```
clickした時、 actions.deleteTodo()を実行します。




```js:actions.deleteTodo()とactions.getTodo()
const actions = {
    getTodo:  () => (state,actions) => {
        axios.get("/api/todos").then(res => {
            console.log(res.data)
            actions.setTodo(res.data.todos)
        })
    },
    deleteTodo: id => (state,actions) => {
        console.log(id)
        axios.delete("/api/todos/" + id).then(resp => {
            console.log(resp.data)
        }).catch(error => {
            console.log(error)
        })
        actions.getTodo()
    }
}
```
actions.deleteTodo()では、引数のidのTodoを消去するため、```/api/todos```へDELETEします。
そして、actions.getTodo()実行し、Todoの一覧を再取得しています。


# ソースコード
Github:
https://github.com/anharu2394/flask-hyperapp-todo_app


# 感想
自分でAPIを書くこと(、フロントのフレームワークでAPIを叩くことなかったのでとても楽しかったです。


FlaskではRailsのActiveRecordのようなものが最初っからない（MVCではない）ので、RailsでWebアプリを作るのとは違った感覚でした。

もちろんRails APIで書いた方が早い
しかし、ただ楽しい

Todoアプリのdbはテーブルがひとつしかないので、もう少し複雑なアプリもflask + Hyperappで作ってみたい。（めんどくさいだろう）

Rails API + Hyperappもやってみたい

今、作りたい機械学習のモデルがあって、それをWebAPI化するのに、この経験を活かせると思います。


# 　ぜひ、Flask + Hyperappで簡単なWebアプリを作ってみてください！

### 参考文献
http://astlab.jp/%E3%83%95%E3%83%AD%E3%83%B3%E3%83%88%E3%82%B5%E3%82%A4%E3%83%89/flask%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E3%80%81json%E3%82%92react%E3%81%A7%E8%A1%A8%E7%A4%BA

https://qiita.com/yamadar/items/bd1e8dbc94afe20cdb9a

https://qiita.com/msrks/items/673c083ca91f000d3ed1

https://medium.com/@thejasonfile/fetch-vs-axios-js-for-making-http-requests-2b261cdd3af5