小村のポートフォリオサイト開発(18) ページネーションを追加する(1)

  • こんばんは、小村だよ!

  • 今日も下記のポートフォリオサイトを構築していくよ

  • 前回storeを作成したので、今回はその機能を増やしていくよ!

  • よろしくね!!!



目次

  1. Entry取得時にページネーションを追加する
  2. ページ切替処理を追加する



記録

Entry取得時にページネーションを追加する

  • 現状はシンプルに、api/entriesにアクセスするとDBの全件を取得します

  • このままだと今後記事が増えた時に読み込みが大変ですね!

  • というわけでページネーションを追加していこうと思います。

  • 参考:シンプルブログ、記事一覧ページの作成③

    • この参考サイトもシンプルでいいですね
  • では早速実装していきましょう!


f:id:kom314_prog:20210906211151p:plain
  • ページネーション用のクラスを作成し、デフォルト値を設定します。

  • あとはviewpagination_class = EntryViewPaginationを加えれば完成

  • UIからAPIを呼び出すと下記のようになります


f:id:kom314_prog:20210906211415p:plain
  • responseにcount,next,previous,resultsが加わりました!

  • 返り値が変わったためデータを正しく取得できず、UIがエラーとなってますね

  • 正しく取得してあげましょう!


f:id:kom314_prog:20210906213400p:plain
  • データの格納場所がrequest.dataからrequest.data.resultsに移動しました。

  • これで取得するレコード数がPaginationで定義した数になりましたね。


f:id:kom314_prog:20210906213818p:plain
  • 3件だけだと上に寄ってる感がすごいな……



ページ切替処理を追加する

  • ではページを切り替えられるようにしていきましょう

  • まず、storeのentry.jsにpageを用意して、pageの値を取得、セットするgettars,mutation,actionsを用意します

  • それからfetchEntriesではurlを引数に取るようにしましょうか

f:id:kom314_prog:20210906223914p:plain


  • 次はコンポーネント側でstoreのpageを読み書きできるようにします

  • computedにpage:{get(){},set(){}}を記述することで実現できます

  • あとはこのpageの値に応じてURLを変更するgetFetchUrlを用意して

  • <v-pagenation>を操作時に再読み込みする処理を追加すれば完成!

f:id:kom314_prog:20210906224244p:plain


f:id:kom314_prog:20210906224323p:plain


f:id:kom314_prog:20210906224401p:plain
  • わーい!

  • ちゃんとページの切替できてますね!



おわりに

  • まだページ遷移処理は少し残ってます

  • 具体的には、ページネーション数をレコード数に応じて変化させたり

  • ソート順も降順にしたいですね。最新の記事が手前に欲しい

  • そのあたりを次回にやりたいとおもいます!

  • それでは!ちゃおちゃお~~~!



小村のポートフォリオサイト開発(17) API呼び出しをstoreに記述する

f:id:kom314_prog:20210904152907p:plain
  • サクサクっとサムネイル画像を表示できるようにUIを修正しました。

    • 修正箇所は割愛。
  • 画像入るとだいぶそれっぽく見えますねー

  • 今日は何をしようかなーーー

  • ひとまず今は単純にAPI呼び出ししてますが、今後にそなえてstoreに移動しましょうか。

  • そうしないと他の箇所で使うときに困ることになりますからねー



目次

  1. 現状整理
  2. storeを記述



記録

現状整理

f:id:kom314_prog:20210904154112p:plain



  • この場合、他のコンポーネントではAPIの値を取得できません

  • APIの呼び出しなどを1ヵ所で行い、このBrogCardはそこから値を呼び出すのが好ましいです

  • そこで出てくるのがstoreになります

  • 参考:Nuxt.js store

  • storeはvuexの仕組みを適用したNuxt.jsのフォルダになります

  • API以外にも、他のコンポーネントと値を受け渡したいときはこのstoreを経由します。



storeを記述

export const state = () => ({
  itemsloading: false,
  items: [],
})

export const getters = {
  getEntryItemsLoading(state) {
    return state.itemsloading
  },
  getEntryItems(state) {
    return state.items
  },
}

export const mutations = {
  SET_ITEMS_LOADING(state, bool) {
    state.itemsloading = bool
  },
  SET_ITEMS(state, items) {
    state.items = items
  },
}

export const actions = {
  async fetchEntries({ commit }, params) {
    commit('SET_ITEMS_LOADING', true)
    const response = await this.$axios.get('/api/entries/')
    const items = response.data
    commit('SET_ITEMS', items)
    commit('SET_ITEMS_LOADING', false)
  },
}


  • そしてBlogCard.vueを下記のように修正
f:id:kom314_prog:20210905183552p:plain
f:id:kom314_prog:20210905183617p:plain


  • このようにして、API呼び出し部分をstoreに記述し、それを呼び出すように変更できました

  • 画面表示は変わっておりません

f:id:kom314_prog:20210905183743p:plain
  • 注意点として、async awaitでデータを取得しに行っている間にその格納予定の変数を画面から覗くとエラー吐くので、ロード中はアクセスしないようにしましょう!

  • 上図の<div v-if="!getEntryItemsLoading">がその役割を担ってます



おわりに

  • 基礎と言いながらエラーと戦って数時間かかりました!

  • 業務ではコピペで済ましてた所だったので、改めて自分で調べながら書くと変なところでつまづいたりしますね。

  • 今後は、このstoreを拡張しつつ、ページネーションを作ったりカテゴリをちゃんと表示したりします!

  • ではでは、ちゃお~~~!



小村のポートフォリオサイト開発(16) データ加工 MD形式に図のURLを埋め込む

  • こんばんは、小村だよ!

  • 今日も下記のポートフォリオサイトを構築していくよ

  • 前回APIを利用してUI側にデータ取得するところまで完了しました!

  • 色々やることあるけど、今日はデータ加工を主にやっていきます!



目次

  1. HTML形式とMD形式の図の共通点を見つける
  2. 方針決め
  3. 実装



記録

HTML形式とMD形式の図の共通点を見つける

f:id:kom314_prog:20210904092123p:plain
  • 現状、取得したデータをそのままWebサイトに表示するのに問題があります。

  • まずWeb内で使用するデータは内容_MarkDownを使用する予定です。

  • しかし、MarkDown形式では図の通りはてなブログ前提のURL表記になってます。

  • これをちゃんとしたURLにしたものが、HTML形式のものに載ってますね。

  • というわけで、このURLを抜き出してMarkDown形式側を差替えする必要があります

  • まずは共通点を探すように、見比べてみましょうか

[f:id:kom314_prog:20210526210606p:plain]|
<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kom314_prog/20210526/20210526210606.png" alt="f:id:kom314_prog:20210526210606p:plain" title="" class="hatena-fotolife" itemprop="image">
  • ふむふむ!!!

  • 何個か比べてみてみましたが、ほとんど共通形式として下記が見つかりました


markdown

  • 頭は右記から始まる [f:id:kom314_prog:

  • 中間の数字はおそらくYYYYMMDDHHmmss

  • 中間の数字の最後のpは拡張子?(pはpng?)

  • 最後は:plain]|


html

  • 頭は右記から始まる <img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kom314_prog/

  • 続きは、YYYYMMDD/YYYYMMDDHHmmss

  • 続きに拡張子 + "

  • 続きに alt="markdownの文字そのまま"



方針決め

  • これルールがわかれば、HTMLから取ってこなくてもMarkDownのを書き換えれますね

  • まずはMarkDownの中で、[f:id:kom314_prog:の文字を<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kom314_prog/に変換

  • [f:id:kom314_prog:の後ろの14文字の数字を取得し、左から8文字/14文字に変換

  • 後ろのp:plain].png">に変換

  • これでひとまず画像のURLを取得できそうですね。

  • ではコーディングしてみましょう!



実装

f:id:kom314_prog:20210904115642p:plain
  • 実装完了!

  • Markdown形式の内容の中の画像のURLが<img>タグに変換されてますね。

  • 最終的にはもっと別の形式に変えるかもしれませんが、ひとまずこれでOK

  • 最終的な修正箇所とソースを張り付けておきます



f:id:kom314_prog:20210904115930p:plain
f:id:kom314_prog:20210904120002p:plain
f:id:kom314_prog:20210904120048p:plain



import os
import requests
import xmltodict


class HatenaApi():
    HATENA_API_USER = os.environ.get('HATENA_API_USER')
    HATENA_API_BLOG = os.environ.get('HATENA_API_BLOG')
    HATENA_API_KEY = os.environ.get('HATENA_API_KEY')

    HATENA_API_URL_HEADER = 'https://blog.hatena.ne.jp'
    HATENA_API_URL_FUTTER = 'atom'

    IMG_SIMPLE_URL_HEADER = '[f:id:kom314_prog:'
    IMG_SIMPLE_URL_FOOTER = ':plain]'
    IMG_SIMPLE_URL_EXTENSION = {'p': 'png'}

    IMG_FULL_URL_HEADER = '<img src="'
    IMG_FULL_URL_TEMPLATE = 'https://cdn-ak.f.st-hatena.com/images/fotolife/k/kom314_prog' + \
        '/YYYYMMDD/YYYYMMDDHHMMSS.EXTENSION'
    IMG_FULL_URL_FOOTER = '">'

    def getAllEntries(self):

        all_entries = []
        url = self.getHatenaApiFirstUrl('entry')

        while url != '':
            hatenaApiData = self.getHatenaApi(url)

            for entry in self.getApiEntries(hatenaApiData):
                all_entries.append(self.formatEntry(entry))
            url = self.getHatenaApiNextUrl(hatenaApiData)

        return all_entries

    def getHatenaApi(self, url):
        auth = self.getHatenaApiAuth()
        hatena_list = requests.get(url, auth=auth)
        dict_data = xmltodict.parse(hatena_list.text, encoding='utf-8')
        return dict_data

    def getApiEntries(self, hatenaApiData):
        return self.getDictValue(hatenaApiData, ['feed', 'entry'])

    def formatEntry(self, entry):
        hatena_entry_id = self.getHatenaEntryId(entry)
        category = self.getCategory(entry)
        title = self.getDictValue(entry, ['title'])
        summary = self.getDictValue(entry, ['summary', '#text'])
        content_md = self.getDictValue(entry, ['content', '#text'])
        content_html = self.getDictValue(
            entry, ['hatena:formatted-content', '#text'])
        draft = self.getDictValue(entry, ['app:control', 'app:draft'])
        updated_at = self.getDictValue(entry, ['updated'], None)
        edited_at = self.getDictValue(entry, ['app:edited'], None)

        img_url_list = self.getImgUrlList(content_md)
        summry_img = self.getSummryImg(img_url_list)
        content = self.getContent(content_md, img_url_list)

        print(content)

        format_entry = {}
        format_entry['hatena_entry_id'] = hatena_entry_id
        format_entry['category'] = category
        format_entry['title'] = title
        format_entry['summary'] = summary
        format_entry['summry_img'] = summry_img
        format_entry['content'] = content
        format_entry['content_md'] = content_md
        format_entry['content_html'] = content_html
        format_entry['draft'] = draft
        format_entry['updated_at'] = updated_at
        format_entry['edited_at'] = edited_at

        return format_entry

    def getHatenaEntryId(self, entry):
        id_all = self.getDictValue(entry, ['id'])
        id = id_all[id_all.rfind('-') + 1:] if id_all else ''
        return id

    def getCategory(self, entry):
        category_all = self.getDictValue(entry, ['category'])
        if isinstance(category_all, list):
            category = category_all[0]
        else:
            category = category_all
        return self.getDictValue(category, ['@term'])

    def getImgUrlList(self, content_md):
        if not content_md:
            return ''

        # 画像の簡易URLを抜き出す
        img_url_list = []
        start_position = 0
        end_position = 0
        while content_md.find(
                self.IMG_SIMPLE_URL_HEADER,
                end_position) >= 0:
            start_position = content_md.find(
                self.IMG_SIMPLE_URL_HEADER, end_position)
            end_position = content_md.find(
                self.IMG_SIMPLE_URL_FOOTER, start_position) + len(self.IMG_SIMPLE_URL_FOOTER)
            simple_url = content_md[start_position:end_position]

            # 簡易URLから日付と拡張子を取得
            yyyymmddhhmmss = simple_url[len(self.IMG_SIMPLE_URL_HEADER):len(
                self.IMG_SIMPLE_URL_HEADER) + 14]
            simple_url_extension = simple_url[len(
                self.IMG_SIMPLE_URL_HEADER) + 14:len(self.IMG_SIMPLE_URL_HEADER) + 15]

            # 直リンク可能なURLを生成する
            full_url = self.IMG_FULL_URL_TEMPLATE
            full_url = full_url.replace('YYYYMMDDHHMMSS', yyyymmddhhmmss)
            full_url = full_url.replace('YYYYMMDD', yyyymmddhhmmss[:8])
            full_url = full_url.replace(
                'EXTENSION', self.IMG_SIMPLE_URL_EXTENSION[simple_url_extension])

            img_url_list.append([simple_url, full_url])
        return img_url_list

    def getSummryImg(self, img_url_list):
        if not isinstance(img_url_list, list) or len(img_url_list) == 0:
            return ''
        if not isinstance(img_url_list[0], list) or len(img_url_list[0]) == 0:
            return ''
        return img_url_list[0][1]

    def getContent(self, content_md, img_url_list):
        content = content_md
        for img_url in img_url_list:
            if not isinstance(img_url, list):
                break
            content = content.replace(img_url[0], self.getImgTag(img_url[1]))
        return content

    def getImgTag(self, img_url):
        return self.IMG_FULL_URL_HEADER + img_url + self.IMG_FULL_URL_FOOTER

    def getHatenaApiFirstUrl(self, action):

        url = [
            self.HATENA_API_URL_HEADER,
            self.HATENA_API_USER,
            self.HATENA_API_BLOG,
            self.HATENA_API_URL_FUTTER,
            action
        ]
        return os.path.join(*url)

    def getHatenaApiNextUrl(self, hatenaApiEntries):
        url = ''
        links = self.getDictValue(hatenaApiEntries, ['feed', 'link'])
        if isinstance(links, list):
            for link in links:
                if self.getDictValue(link, ['@rel']) == 'next':
                    url = self.getDictValue(link, ['@href'])
        return url

    def getHatenaApiAuth(self):

        return (self.HATENA_API_USER, self.HATENA_API_KEY)

    def getDictValue(self, dictData, keys, emptyValue=''):
        for key in keys:
            if isinstance(dictData, dict):
                if key in dictData:
                    dictData = dictData[key]
            else:
                return emptyValue
        return dictData



おわりに

  • せっかく今回URL変換したのに、詳細画面を実装してないから最終確認できない!

  • はやめに詳細画面実装したいなー

  • ではでは今日はこの辺で!ちゃお~~~!



小村のポートフォリオサイト開発(15) Axiosを使用したAPI呼び出し

  • こんばんは、小村だよ!

  • 今日も下記のポートフォリオサイトを構築していくよ

  • 今回は久しぶりにUI側を触るよ!

  • ここまで書いてきたAPIをついにUI側から呼び出しちゃおうと思います!



目次

  1. Axiosの利用
  2. CORSエラーを解消する
  3. トップページに表示



記録

Axiosの利用

  • ではでは、APIをUIから呼び出していきまっしょい!

  • 参考:Nuxt.jsでaxiosの使い方と設定方法を紹介

  • Nuxt.jsからAPIを呼ぶ際、Axiosというライブラリを使うことが多いです

  • Nuxt.jsでプロジェクト作成時に、私はAxiosにチェックを入れたのでばっちり導入済でした!

f:id:kom314_prog:20210901214521p:plain


  • じゃあさくっと動作確認用のコードをUI側に書いてさくっと実行しよう!

  • ほんとに動作確認のためだけのコードを書いたので実行!

f:id:kom314_prog:20210901221448p:plain
f:id:kom314_prog:20210901221518p:plain
f:id:kom314_prog:20210901221753p:plain



f:id:kom314_prog:20210901222025p:plain
  • エラーやんけ!!!!!!

  • びぇ~~~~ん!!!どらえも~~~ん!!!



CORSエラーを解消する

参考:【解決済】Docker上のNuxtでaxiosのCORSエラーにハマりまくった話

  • 解消方法は下記の2つがあるみたいです

    • メッセージの通りヘッダにAccess-Control-Allow-Originを付ける

    • プロキシを設定する

  • 今回は参考サイトの通りproxyを設定してみましょう。

  • nuxt.config.jsを開き、下記の通り変更します

f:id:kom314_prog:20210901224533p:plain
f:id:kom314_prog:20210901225746p:plain



f:id:kom314_prog:20210901225909p:plain
  • ひゃっほぉぉぉぉおおおおお!!!!

  • 無事取得できました!!!!!!!!!!



トップページに表示

  • せっかく取得できたのでサイトに表示してみます!

  • 色々エラーと格闘して最終的にこんな感じになりました!

f:id:kom314_prog:20210901234447p:plain
f:id:kom314_prog:20210901234523p:plain



おわりに

  • 今日はこれでおしまい!

  • やーーーっとスタートラインに立った感じですかね!

  • こうやって日を置いてみるとサイトが物足りないな……

  • 色々やること浮かんできますが、一つずつかたずけていきましょう!

  • ではでは、ちゃお~~~!



小村のポートフォリオサイト開発(14) DjangoRestFramework ここまでのリファクタリング

  • こんばんは、小村だよ!

  • [DRF レスポンスデータ]みたいな感じで検索したらこのブログがヒットしてびっくりしたよ!

  • じゃー今日も下記のポートフォリオサイトを構築していくよ

  • 前回はAPIのレスポンスのフォーマットを決めたね

  • 今日はここまで書いてきたAPIリファクタリングしていこうと思います!



目次

  1. 現状整理
  2. utils.hatena.py作成
  3. views.entry.py修正



記録

現状整理

  • まずは現状のviews.entry.pyの中身が下記

  • fat viewもいいところだね!

  • 基本的にviewはコントローラー部分だけ書くべきなのでめちゃんこよくない例です。

  • このソースをリファクタリングしていきたいと思います

# coding: utf-8
import time
import os
import requests
import xmltodict


from rest_framework import status
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response

from ..models import Entry
from ..serializer import EntryAllSerializer, EntryCreateAndUpdateSerializer


class EntryViewSet(viewsets.ModelViewSet):
    queryset = Entry.objects.all()
    serializer_class = EntryAllSerializer

    @action(detail=False, methods=['post'])
    def capture(self, request):
        url = self.getHatenaApiUrl('entry')
        auth = self.getHatenaApiAuth()
        hatena_entry_ids = []
        response = {
            'created_count': 0,
            'updated_count': 0,
            'deleted_count': 0,
            'failed_count': 0,
            'failed_hatena_entry_ids': [],
        }

        while url != '':
            # 連続で呼び出すの怖いので0.5秒間間を空ける
            time.sleep(0.5)

            hatena_list = requests.get(url, auth=auth)
            dict_data = xmltodict.parse(hatena_list.text, encoding='utf-8')
            entries = []
            if 'feed' in dict_data:
                if 'entry' in dict_data['feed']:
                    entries = dict_data['feed']['entry']
            for entry in entries:
                if not isinstance(entry, dict):
                    continue

                # hatena_entry_id取得
                hatena_entry_id = entry['id'][
                    entry['id'].rfind('-') + 1:] if 'id' in entry else ''

                hatena_entry_ids.append(hatena_entry_id)

                # category取得
                if 'category' in entry:
                    if isinstance(entry['category'], list):
                        category = entry['category'][0]['@term']
                    else:
                        category = entry['category']['@term']
                else:
                    category = ''

                # title取得
                title = entry['title'] if 'title' in entry else ''

                # summary取得
                summary = entry['summary']['#text'] if 'summary' in entry else ''

                # content_md取得
                content_md = entry['content']['#text'] if 'content' in entry else ''

                # content_html取得
                content_html = entry['hatena:formatted-content']['#text'] if 'hatena:formatted-content' in entry else ''

                # draft取得
                draft = entry['app:control']['app:draft'] if 'app:control' in entry else ''

                # updated_at取得
                updated_at = entry['updated'] if 'updated' in entry else None

                # edited_at取得
                edited_at = entry['app:edited'] if 'app:edited' in entry else None

                # 更新用パラメータ
                param = {
                    'hatena_entry_id': hatena_entry_id,
                    'category': category,
                    'title': title,
                    'summary': summary,
                    'content_md': content_md,
                    'content_html': content_html,
                    'draft': draft,
                    'updated_at': updated_at,
                    'edited_at': edited_at,
                }

                entry = Entry.objects.filter(
                    hatena_entry_id=hatena_entry_id).first()

                if not entry:
                    # 新規作成
                    mode = 'create'
                    serializer = EntryCreateAndUpdateSerializer(data=param)

                else:
                    # 更新
                    mode = 'update'
                    serializer = EntryCreateAndUpdateSerializer(
                        entry, data=param)

                if serializer.is_valid():
                    serializer.save()
                    if mode == 'create':
                        response['created_count'] = response['created_count'] + 1
                    else:
                        response['updated_count'] = response['updated_count'] + 1
                else:
                    response['failed_count'] = response['failed_count'] + 1
                    response['failed_hatena_entry_ids'].append(hatena_entry_id)

            # 次のURLを取得
            url = ''
            if 'link' in dict_data['feed']:
                if isinstance(dict_data['feed']['link'], list):
                    for link in dict_data['feed']['link']:
                        if isinstance(link, dict):
                            if '@rel' in link:
                                if link['@rel'] == 'next':
                                    url = link['@href']

        # はてなIDが存在しなければ論理削除
        response['deleted_count'] = Entry.objects.exclude(
            hatena_entry_id__in=hatena_entry_ids).delete()

        return Response(response, status.HTTP_200_OK)

    def getHatenaApiUrl(self, action):
        HATENA_API_URL_HEADER = 'https://blog.hatena.ne.jp'
        HATENA_API_USER = os.environ.get('HATENA_API_USER')
        HATENA_API_BLOG = os.environ.get('HATENA_API_BLOG')
        HATENA_API_URL_FUTTER = 'atom'

        url = [
            HATENA_API_URL_HEADER,
            HATENA_API_USER,
            HATENA_API_BLOG,
            HATENA_API_URL_FUTTER,
            action
        ]
        return os.path.join(*url)

    def getHatenaApiAuth(self):
        HATENA_API_USER = os.environ.get('HATENA_API_USER')
        HATENA_API_KEY = os.environ.get('HATENA_API_KEY')

        return (HATENA_API_USER, HATENA_API_KEY)



utils.hatena.py作成

  • そもそもはてなブログAPIに関する処理はEntryと直接関係ないですね。

  • ということでutilsフォルダ内にhatena.pyを作成して、はてなブログAPI関係の処理は全てここにまとめようと思います

  • これによりHatenaApiをインスタンス化してgetAllEntriesを実行するといい感じに加工された記事データが取得できるようになりました

f:id:kom314_prog:20210830234554p:plain
import os
import requests
import xmltodict


class HatenaApi():
    HATENA_API_USER = os.environ.get('HATENA_API_USER')
    HATENA_API_BLOG = os.environ.get('HATENA_API_BLOG')
    HATENA_API_KEY = os.environ.get('HATENA_API_KEY')

    HATENA_API_URL_HEADER = 'https://blog.hatena.ne.jp'
    HATENA_API_URL_FUTTER = 'atom'

    def getAllEntries(self):

        all_entries = []
        url = self.getHatenaApiFirstUrl('entry')

        while url != '':
            hatenaApiData = self.getHatenaApi(url)

            for entry in self.getApiEntries(hatenaApiData):
                all_entries.append(self.formatEntry(entry))
            url = self.getHatenaApiNextUrl(hatenaApiData)

        return all_entries

    def getHatenaApi(self, url):
        auth = self.getHatenaApiAuth()
        hatena_list = requests.get(url, auth=auth)
        dict_data = xmltodict.parse(hatena_list.text, encoding='utf-8')
        return dict_data

    def getApiEntries(self, hatenaApiData):
        return self.getDictValue(hatenaApiData, ['feed', 'entry'])

    def formatEntry(self, entry):
        format_entry = {}
        format_entry['hatena_entry_id'] = self.getHatenaEntryId(entry)
        format_entry['category'] = self.getCategory(entry)
        format_entry['title'] = self.getDictValue(entry, ['title'])
        format_entry['summary'] = self.getDictValue(
            entry, ['summary', '#text'])
        format_entry['content_md'] = self.getDictValue(
            entry, ['content', '#text'])
        format_entry['content_html'] = self.getDictValue(
            entry, ['hatena:formatted-content', '#text'])
        format_entry['draft'] = self.getDictValue(
            entry, ['app:control', 'app:draft'])
        format_entry['updated_at'] = self.getDictValue(
            entry, ['updated'], None)
        format_entry['edited_at'] = self.getDictValue(
            entry, ['app:edited'], None)

        return format_entry

    def getHatenaEntryId(self, entry):
        id_all = self.getDictValue(entry, ['id'])
        id = id_all[id_all.rfind('-') + 1:] if id_all else ''
        return id

    def getCategory(self, entry):
        category_all = self.getDictValue(entry, ['category'])
        if isinstance(category_all, list):
            category = category_all[0]
        else:
            category = category_all
        return self.getDictValue(category, ['@term'])

    def getHatenaApiFirstUrl(self, action):

        url = [
            self.HATENA_API_URL_HEADER,
            self.HATENA_API_USER,
            self.HATENA_API_BLOG,
            self.HATENA_API_URL_FUTTER,
            action
        ]
        return os.path.join(*url)

    def getHatenaApiNextUrl(self, hatenaApiEntries):
        url = ''
        links = self.getDictValue(hatenaApiEntries, ['feed', 'link'])
        if isinstance(links, list):
            for link in links:
                if self.getDictValue(link, ['@rel']) == 'next':
                    url = self.getDictValue(link, ['@href'])
        return url

    def getHatenaApiAuth(self):

        return (self.HATENA_API_USER, self.HATENA_API_KEY)

    def getDictValue(self, dictData, keys, emptyValue=''):
        for key in keys:
            if isinstance(dictData, dict):
                if key in dictData:
                    dictData = dictData[key]
            else:
                return emptyValue
        return dictData



views.entry.py修正

  • あとは今までのentry.pyから上記処理を呼び出して、登録・更新・削除を行うようにします

  • もうちょっと短くなると思ってたのですが、思いのほか処理減らなかったな……

  • でもまぁかなり読みやすくなったのでよし!!!

f:id:kom314_prog:20210830235143p:plain
# coding: utf-8
from rest_framework import status
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response

from ..models import Entry
from ..utils import HatenaApi
from ..serializer import EntryAllSerializer, EntryCreateAndUpdateSerializer


class EntryViewSet(viewsets.ModelViewSet):
    queryset = Entry.objects.all()
    serializer_class = EntryAllSerializer

    @action(detail=False, methods=['post'])
    def capture(self, request):
        hatena_api = HatenaApi()
        all_entries = hatena_api.getAllEntries()
        all_entries_sort = sorted(all_entries, key=lambda x: x['edited_at'])
        response = {
            'created_count': 0,
            'updated_count': 0,
            'deleted_count': 0,
            'failed_count': 0,
            'failed_hatena_entry_ids': [],
        }
        hatena_entry_ids = []
        for param in all_entries_sort:
            hatena_entry_id = param['hatena_entry_id']
            hatena_entry_ids.append(hatena_entry_id)
            # 更新用パラメータ
            entry = Entry.objects.filter(
                hatena_entry_id=hatena_entry_id).first()

            if not entry:
                # 新規作成
                mode = 'create'
                serializer = EntryCreateAndUpdateSerializer(data=param)

            else:
                # 更新
                mode = 'update'
                serializer = EntryCreateAndUpdateSerializer(
                    entry, data=param)

            if serializer.is_valid():
                serializer.save()
                if mode == 'create':
                    response['created_count'] = response['created_count'] + 1
                else:
                    response['updated_count'] = response['updated_count'] + 1
            else:
                response['failed_count'] = response['failed_count'] + 1
                response['failed_hatena_entry_ids'].append(
                    hatena_entry_id)

        # はてなIDが存在しなければ論理削除
        response['deleted_count'] = Entry.objects.exclude(
            hatena_entry_id__in=hatena_entry_ids).delete()

        return Response(response, status.HTTP_200_OK)



おわりに

  • リファクタリングが完了しました!

  • 取込系の処理に関してはひとまずこれでよさそうかなーと思います

  • あ、でもテスト作ってないや。ユニットテスト作らなきゃだね

  • 次回当たりユニットテスト作っていきたいと思います!

  • ではでは、ちゃお~~~!



小村のポートフォリオサイト開発(13) DjangoRestFramework レスポンスデータフォーマット

  • こんばんは、小村だよ!

  • 下記のポートフォリオサイトを構築していくよ

  • 前回記事の論理削除対応をしたね

  • 今回はAPIのレスポンスのフォーマットを決めていきたいと思います



目次

  1. APIのレスポンスのフォーマットを決める
  2. 記事取込処理の正常系レスポンスを決める
  3. 全体共通のエラー時のレスポンスを決める



記録

APIのレスポンスのフォーマットを決める

参考:Udemy REST WebAPI サービス 設計 26. データの内部構造

参考:WebAPIでエラーをどう表現すべき?15のサービスを調査してみた」についてまとめた

  • APIのレスポンスは、業務上では下記のようなフォーマットで返してました
{
  result: 1 or 0
  data: { ... }
}
  • 改めて今回調べたところ、resultなどは返さないのが一般的のようですね?

  • 何が一番いいのか色々見ましたが最終的にUdemyで上記講座に書かれていた方針でいきます

    • エンベロープは使わない

      • エンベロープ=レスポンスボディ内のメタ情報。ヘッダ情報と役割がかぶるため不要
    • オブジェクトはできるだけフラット

      • レスポンス容量を減らすため
    • エラー詳細はレスポンスボディに追加する

      • 下記の形式とする
{
  code: xx
  detail: ''
}



記事取込処理の正常系レスポンスを決める

  • ではでは、今回の記事取込処理のレスポンスを決めていきましょう

  • まず、正常に処理が行われたときは下記を返すようにしましょうか

http_status: 200

{
  created_count: x,
  updated_count: x,
  deleted_count: x,
  failed_count: x,
  failed_ids: [x, x, x]
}
  • 正常かどうかはレスポンスヘッダで確認します。

  • HTTP 200 OK はリクエストが成功した場合に返すレスポンスコードですね

  • それから作成、更新、削除件数を返します。

  • 最後に入力チェックではじかれた件数とIDを返すようにしましょうか

  • そんな感じにViewsを修正します

  • 修正して実行した結果が最後の図。問題なさそうですね!

f:id:kom314_prog:20210828171542p:plain
f:id:kom314_prog:20210828171628p:plain
f:id:kom314_prog:20210828171728p:plain



全体共通のエラー時のレスポンスを決める


f:id:kom314_prog:20210828194753p:plain
  • 参考サイトを参考に、utiles.handler.pyを作成して図のように記載


f:id:kom314_prog:20210828194437p:plain
  • これをsettingsに上記画像のように書くのだけど、そこで2時間くらいエラーと戦いました。

  • 結果として、上記の書き方です。

    • エラーで詰まったら飯食うの大事。一瞬で解けた。フォルダパスの書き方が原因だった


f:id:kom314_prog:20210828203342p:plain
  • 実装してエラー吐いた結果がこれ

  • 本当は出力をmessageにしたかったのにdetailで出てしまうのは自分の実力不足でする……

  • 業務の方のシステムだとdetailなんて吐いてないので、実装覗いてきて改良します



おわりに

  • やっとこさ取込機能に関する機能の実装は一通りおしまい!

  • あとはリファクタリングしたらweb側の実装に戻れます!

  • あともうちょいだ!がんばろー!

  • ではでは、ちゃお~~~!



小村のポートフォリオサイト開発(12) DjangoRestFramework はてなブログの記事 論理削除対応

  • こんばんは、小村だよ!

  • 下記のポートフォリオサイトを構築していくよ

  • 前回はてなブログ記事をすべてDBに書き込むことに成功しました

  • 今回は論理削除対応と銘打って進めていきます



目次

  1. 削除日時を追加
  2. 削除日時を入れる処理追加



記録

削除日時を追加

  • 自前のDBはバックアップとしての機能も期待しています。

  • そのため、はてなブログの記事が消えたとしても、削除はしません

  • そのかわりに、削除されていたら削除日を更新して記事の表示はしないようにします

  • ではまずmodelに削除日を用意しましょう

  • 追加した後のマイグレーションファイルは下記のようになりました

f:id:kom314_prog:20210828092700p:plain



削除日時を入れる処理追加


f:id:kom314_prog:20210828105053p:plain
f:id:kom314_prog:20210828105130p:plain
  • まずはviews

  • 取込処理をしているなかで存在するhatena_entry_idを蓄え、最後になければdeleteかけてます

  • ここは特に問題ないですね


f:id:kom314_prog:20210828105522p:plain
f:id:kom314_prog:20210828105550p:plain
  • 本題のmodels

  • あらたにQuerySetModelManagerの概念が加わってます。

  • こちら業務で使ってますが詳しくは調べたことないのであとで調べてみますね

  • 動作としては

    • delete()を実行すると論理削除する

    • delete_hard()を実行すると物理削除する

    • Entry.objectsでデータを覗くと論理削除データは省かれる

    • Entry.entireでデータを覗くと論理削除データも含む

  • という動きになります。どちゃくそ便利!



動作確認

f:id:kom314_prog:20210828112054p:plain
f:id:kom314_prog:20210828112129p:plain


  • そしたらはてなブログで記事を消して、ふたたび取り込みます

  • 再取り込み後のデータが下記。ちゃんと削除日時が入ってますね!

f:id:kom314_prog:20210828112540p:plain



DjangoQuerySetModelManagerについて

  • Modelsで出てきたQuerySetModelManagerについて調べてみます

  • 業務で使ってるのでなんとなくは使えてますが、改めて意味を調べてみます


Manager

  • 参考:Django ドキュメント マネージャ

  • 今までなんとなしにEntry.objects.all()とかでデータを取得できてたのはこのManagerのおかげ

  • デフォルトでobjectsにアクセスした際、動くようになっているようですね。

  • 今回のようにentireの中でManagerを入れると、Entry.entireでアクセスできるようになります。

  • そうしてアクセスした際の動きを、下記のQuerySetなどで制御するようですね。


QuerySet

  • 参考:Django ドキュメント QuerySet

  • QuerySetは実際にDBにアクセスせずにデータを加工したりする仕組みのようです。へー!

  • models.QuerySetが用意されていて、その中に基本のgetやfilterやexcludeなどが用意されてます

  • そのうえで、今回のような決まりきった削除などの更新処理も定義できるようですね。

  • DBにアクセスする際の抽出条件や処理条件を定義する役目ということでしょうね。



おわりに

  • 論理削除の処理と合わせて、Djangoの基礎知識についても知れました

  • 上記記事について熟読ができてないので、改めて後で読んでみようと思います。

  • 次は、APIの返り値の設定をしていきたいと思います。

  • ではでは、ちゃお~~~!