diff --git a/.env.testing b/.env.testing index 4737a59..1d4e1d8 100644 --- a/.env.testing +++ b/.env.testing @@ -1,5 +1,5 @@ DATABASE_HOST=127.0.0.1 -DATABASE_PORT=3306 +DATABASE_PORT=5432 DATABASE_USERNAME=vapor_username DATABASE_PASSWORD=vapor_password DATABASE_NAME=test_agqr_program_guide diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 29b25ea..742d2f8 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -1,4 +1,5 @@ name: build-push +run-name: Push [${{ inputs.version }}] from ${{ github.ref_name }} on: workflow_dispatch: @@ -12,18 +13,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v1.5.1 + uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v5 with: push: true tags: ghcr.io/${{ github.repository }}:${{ github.event.inputs.version }} diff --git a/.github/workflows/check-openapi.yaml b/.github/workflows/check-openapi.yaml new file mode 100644 index 0000000..8f56b0b --- /dev/null +++ b/.github/workflows/check-openapi.yaml @@ -0,0 +1,34 @@ +name: Check OpenAPI +on: + push: + branches: + - master + paths: + - '.github/workflows/check-openapi.yaml' + - 'reference/agqr-radio-program-guide-api.v2.yaml' + - 'Resources/Views/redoc-static.html' + pull_request: + branches: + - master + paths: + - '.github/workflows/check-openapi.yaml' + - 'reference/agqr-radio-program-guide-api.v2.yaml' + - 'Resources/Views/redoc-static.html' + +jobs: + exists-diff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: make generate-redoc + - name: Check exists diff + run: | + if ! git diff --quiet; then + exit 1 + fi + # TODO: 後で直す + # lint: + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - run: make lint-openapi diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index eafcd2a..7e49897 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -6,10 +6,23 @@ on: - master jobs: - setup: - runs-on: macos-latest + build: + name: Swift ${{ matrix.swift }} on ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + swift: ["5.9"] + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: swift-actions/setup-swift@v1 + with: + swift-version: ${{ matrix.swift }} + - uses: actions/checkout@v4 - run: swift package resolve - - name: swift test + - name: Build + run: swift build + - name: setup service + run: | + docker compose up -d db --wait + - name: Run tests run: swift test diff --git a/Dockerfile b/Dockerfile index aae292c..f6de261 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,4 +60,4 @@ EXPOSE 8080 # Start the Vapor service when the image is run, default to listening on 8080 in production environment ENTRYPOINT ["./Run"] -CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] \ No newline at end of file +CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] diff --git a/Makefile b/Makefile index 9156d94..bc99607 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,11 @@ migrate: build: docker-compose build --no-cache up: - docker-compose up -d db redis + docker-compose up -d db down: docker-compose down + +.PHONY: generate-redoc +generate-redoc: + docker run --rm -v ${PWD}/reference:/spec redocly/cli build-docs agqr-radio-program-guide-api.v2.yaml -o redoc-static.html + mv reference/redoc-static.html Resources/Views/redoc-static.html diff --git a/Package.resolved b/Package.resolved index 4131c0e..a4b0cf7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -47,12 +47,12 @@ } }, { - "package": "fluent-mysql-driver", - "repositoryURL": "https://github.com/vapor/fluent-mysql-driver.git", + "package": "fluent-postgres-driver", + "repositoryURL": "https://github.com/vapor/fluent-postgres-driver.git", "state": { "branch": null, - "revision": "f86bf9c80a1c176234a16796c19add399a266c38", - "version": "4.0.2" + "revision": "7c266b539f71331ad6e53ea8fae587ccdaf972f2", + "version": "2.2.6" } }, { @@ -92,57 +92,21 @@ } }, { - "package": "mysql-kit", - "repositoryURL": "https://github.com/vapor/mysql-kit.git", + "package": "postgres-kit", + "repositoryURL": "https://github.com/vapor/postgres-kit.git", "state": { "branch": null, - "revision": "f54e0876fce2d68551be0e704b3a94f78eedb9f8", - "version": "4.5.0" + "revision": "35deea5c28a7d402f3280d81dee37bed5c56b9fe", + "version": "2.8.3" } }, { - "package": "mysql-nio", - "repositoryURL": "https://github.com/vapor/mysql-nio.git", + "package": "postgres-nio", + "repositoryURL": "https://github.com/vapor/postgres-nio.git", "state": { "branch": null, - "revision": "f0e8ad7e18e870e8665311d70bf3f01dcd6024a8", - "version": "1.4.0" - } - }, - { - "package": "queues", - "repositoryURL": "https://github.com/vapor/queues.git", - "state": { - "branch": null, - "revision": "58b2d785118d164b38c59beb595cbffbea7608ef", - "version": "1.8.1" - } - }, - { - "package": "queues-redis-driver", - "repositoryURL": "https://github.com/vapor/queues-redis-driver.git", - "state": { - "branch": null, - "revision": "2728477b50e24be82f5bc0bd0722c35656e1c5b1", - "version": "1.0.3" - } - }, - { - "package": "redis", - "repositoryURL": "https://github.com/vapor/redis.git", - "state": { - "branch": null, - "revision": "e955843b08064071f465a6b1ca9e04bebad8623a", - "version": "4.6.0" - } - }, - { - "package": "RediStack", - "repositoryURL": "https://gitlab.com/mordil/RediStack.git", - "state": { - "branch": null, - "revision": "5458d6476e05d5f1b43097f1bc9b599e936b5f2f", - "version": "1.3.0" + "revision": "d648c5b4594ffbc2f6173318f70f5531e05ccb4e", + "version": "1.11.0" } }, { @@ -163,6 +127,15 @@ "version": "3.18.0" } }, + { + "package": "swift-atomics", + "repositoryURL": "https://github.com/apple/swift-atomics.git", + "state": { + "branch": null, + "revision": "cd142fd2f64be2100422d658e7411e39489da985", + "version": "1.2.0" + } + }, { "package": "swift-backtrace", "repositoryURL": "https://github.com/swift-server/swift-backtrace.git", diff --git a/Package.swift b/Package.swift index a5a1534..b32d0a4 100644 --- a/Package.swift +++ b/Package.swift @@ -1,19 +1,17 @@ -// swift-tools-version:5.2 +// swift-tools-version:5.5 import PackageDescription let package = Package( name: "agqr-program-guide", platforms: [ - .macOS(.v10_15) + .macOS(.v12) ], dependencies: [ // 💧 A server-side Swift web framework. .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"), - .package(url: "https://github.com/vapor/fluent-mysql-driver.git", from: "4.0.0"), + .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"), .package(url: "https://github.com/vapor/leaf.git", from: "4.0.0"), - .package(url: "https://github.com/vapor/redis.git", from: "4.0.0"), - .package(url: "https://github.com/vapor/queues-redis-driver.git", from: "1.0.0"), // HTML Parser .package(url: "https://github.com/tid-kijyun/Kanna.git", .upToNextMajor(from: "5.2.4")) ], @@ -22,10 +20,8 @@ let package = Package( name: "App", dependencies: [ .product(name: "Fluent", package: "fluent"), - .product(name: "FluentMySQLDriver", package: "fluent-mysql-driver"), + .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), .product(name: "Leaf", package: "leaf"), - .product(name: "Redis", package: "redis"), - .product(name: "QueuesRedisDriver", package: "queues-redis-driver"), .product(name: "Vapor", package: "vapor"), .product(name: "Kanna", package: "Kanna"), diff --git a/README.md b/README.md index b42649d..6d33fa8 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,7 @@ # agqr-program-guide -[超A&G+の番組表](https://www.joqr.co.jp/qr/agdailyprogram/) から番組情報・出演者を取得し、JSON形式のREST APIとして提供しています。 +[超A&G+の番組表](https://www.joqr.co.jp/qr/agdailyprogram/) から番組情報・出演者を取得し、JSON 形式の REST API として提供しています。 https://agqr.sun-yryr.com で提供中です。 -フレームワークとしてSwift製のVaporを利用しています。 - -## Development - -前提 -```bash -swift --version -# 5.4.2 -vapor --version -# framework: 4.45.0 -# toolbox: 18.3.3 -``` - -1. DBとRedisを起動する。 `make up` -1. 開発する。 -1. サーバーを立てて動作確認する。 `make serve` - -### lint - -swiftlintかswift-formatを利用する予定。更新する。 - -### build & push - -GitHub Actionsのページから `build-push` のワークフローを実行する。 - -### deploy - -1. EC2にログインする。 -1. pushしたDockerImageをPullしてくる。(直接起動する場合は飛ばしてもいい) -1. 環境変数とかを設定して起動する。下記の「使う」を参考にする。 - -## 使う - -適宜タグや環境変数の変更が必要。 - -### マイグレーション -```bash -docker run --rm -e DATABASE_HOST=127.0.0.1 -e REDIS_URL="redis://127.0.0.1:6379" ghcr.io/sun-yryr/agqr-program-guide:latest migrate --yes -``` - -### 手動スクレイピング - -基本的に自動で取ってくるので必要ない。初回起動などで利用する。 -※ hoge は使っていない引数なので後々削除する。 -```bash -docker run --rm -e DATABASE_HOST=127.0.0.1 -e REDIS_URL="redis://127.0.0.1:6379" -e TZ=Asia/Tokyo ghcr.io/sun-yryr/agqr-program-guide:latest scraping hoge -``` - -### 起動 -```bash -docker run -d -e DATABASE_HOST=127.0.0.1 -e REDIS_URL="redis://127.0.0.1:6379" -e TZ=Asia/Tokyo -p 3000:8080 ghcr.io/sun-yryr/agqr-program-guide:latest -``` - -## ライセンス - -そのうち +フレームワークとして [Vapor](https://github.com/vapor/vapor) を利用しています。 diff --git a/Resources/Views/redoc-static.html b/Resources/Views/redoc-static.html new file mode 100644 index 0000000..2b56195 --- /dev/null +++ b/Resources/Views/redoc-static.html @@ -0,0 +1,338 @@ + + + + + + agqr-radio-program-guide-api + + + + + + + + + +

agqr-radio-program-guide-api (1.0)

Download OpenAPI specification:Download

[非公式] 超A&G+ 番組表 API
データ更新頻度: 1日ごと(JST 07:00 ごろ)

+

何かバグ等ありましたら、Issue を立てていただくか下記の連絡先までご連絡ください。
Twitter(X): @sun_yryr
GitHub: https://github.com/sun-yryr/agqr-program-guide

+

v1

v1 API

+

週刊番組表を取得

1週間分全ての番組情報を返却する。 +実行日の日本時間の0時から7日後の23時59分までの間の番組情報を返却する。

+

Responses

Response samples

Content type
application/json
[
  • {
    }
]

日間番組表を取得

本日放送予定の番組情報を返却する。 +本日とは、日本時間の0時から23時59分までの間のことを指す。

+

Responses

Response samples

Content type
application/json
[
  • {
    }
]

現在の番組情報を返却

現在放送中の番組情報を返却する

+

Responses

Response samples

Content type
application/json
[
  • {
    }
]
+ + + + diff --git a/Sources/App/Commands/ImportWeeklyPGCommand.swift b/Sources/App/Commands/ImportWeeklyPGCommand.swift new file mode 100644 index 0000000..3e1a0db --- /dev/null +++ b/Sources/App/Commands/ImportWeeklyPGCommand.swift @@ -0,0 +1,49 @@ +import Fluent +import Vapor + +struct ImportWeeklyPGCommand: Command { + let parser: ProgramGuideParsing + let repository: ProgramGuideSaving + let client = DownloadAgqrProgramGuide() + + struct Signature: CommandSignature {} + + var help: String = "Import weekly program guides into db" + + func run(using context: CommandContext, signature: Signature) throws { + context.console.info("Start Process") + defer { + context.console.info("End Process") + } + + let promise = context.application.eventLoopGroup.next().makePromise(of: Void.self) + promise.completeWithTask { + await self.asyncRun(using: context, signature: signature) + } + + try promise.futureResult.wait() + } + + func asyncRun(using context: CommandContext, signature: Signature) async { + let responses = await client.fetchWeekly(app: context.application) + for response in responses { + guard let response = response else { + context.console.error("NotFound program data") + continue + } + do { + let programGuide = try self.parser.parse(response) + guard programGuide.count > 0 else { + context.console.error("parsed programs length is 0") + continue + } + await self.repository.save(programGuide, app: context.application) + context.console.info("success: \(programGuide[0].program.startDatetime)") + } catch let error as AgqrParseError { + context.console.error(.init(stringLiteral: error.message)) + } catch { + context.console.error(.init(stringLiteral: error.localizedDescription)) + } + } + } +} diff --git a/Sources/App/Commands/ScrapingAgqrCommand.swift b/Sources/App/Commands/ScrapingAgqrCommand.swift index 3018e98..3d4d4db 100644 --- a/Sources/App/Commands/ScrapingAgqrCommand.swift +++ b/Sources/App/Commands/ScrapingAgqrCommand.swift @@ -7,8 +7,8 @@ struct ScrapingAgqr: Command { let client = DownloadAgqrProgramGuide() struct Signature: CommandSignature { - @Option(name: "url") - var url: String? + @Argument(name: "url") + var url: String } var help: String = "Download program guide and parse to json." @@ -18,23 +18,27 @@ struct ScrapingAgqr: Command { defer { context.console.info("End Process") } - let future = client.execute(app: context.application, url: signature.url) - .unwrap(or: fatalError("htmlデータの取得に失敗しました")) - .flatMap { res -> EventLoopFuture in - do { - let programGuide = try self.parser.parse(res) - context.console.info(programGuide.map { element in element.program.startDatetime.toString() }.joined(separator: ",")) - return context.application.eventLoopGroup.future() - } catch { - return context.application.eventLoopGroup.future(error: error) - } - } + let promise = context.application.eventLoopGroup.next().makePromise(of: Void.self) + promise.completeWithTask { + await self.asyncRun(using: context, signature: signature) + } + + try promise.futureResult.wait() + } + + func asyncRun(using context: CommandContext, signature: Signature) async { + let response = await client.execute(app: context.application, url: signature.url) + guard let response = response else { + context.console.error("htmlデータの取得に失敗しました") + return + } do { - try future.wait() + let programGuide = try parser.parse(response) + context.console.info(programGuide.map { element in element.program.startDatetime.toString() }.joined(separator: "\n")) + await repository.save(programGuide, app: context.application) } catch { - print("Batch failure") - print(error.localizedDescription) + context.console.error(.init(stringLiteral: error.localizedDescription)) } } } diff --git a/Sources/App/Jobs/ImportProgramGuideJob.swift b/Sources/App/Jobs/ImportProgramGuideJob.swift deleted file mode 100644 index e45f000..0000000 --- a/Sources/App/Jobs/ImportProgramGuideJob.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation -import Queues -import Vapor - -struct ImportProgramGuideJob: ScheduledJob { - let parser: ProgramGuideParsing - let repository: ProgramGuideSaving - let client = DownloadAgqrProgramGuide() - - func run(context: QueueContext) -> EventLoopFuture { - // TODO: ログの生成場所・時間 - context.logger.info("Start Scraping Process") - defer { - context.logger.info("End Scraping Process") - } - - return client.fetchWeekly(app: context.application).flatMap { responses -> EventLoopFuture in - responses - .map { response -> EventLoopFuture in - guard let response = response else { - return context.application.eventLoopGroup.future(error: "番組表データがありませんでした。") - } - do { - let programGuide = try self.parser.parse(response) - return self.repository.save(programGuide, app: context.application) - } catch let error as AgqrParseError { - context.logger.error(.init(stringLiteral: error.message)) - return context.application.eventLoopGroup.future(error: error) - } catch { - return context.application.eventLoopGroup.future(error: error) - } - } - .flatten(on: context.application.eventLoopGroup.next()) - } - } -} diff --git a/Sources/App/Repositories/ProgramGuideRepository.swift b/Sources/App/Repositories/ProgramGuideRepository.swift index 660bfdf..543cafb 100644 --- a/Sources/App/Repositories/ProgramGuideRepository.swift +++ b/Sources/App/Repositories/ProgramGuideRepository.swift @@ -2,67 +2,61 @@ import Fluent import Vapor protocol ProgramGuideSaving { - func save(_ items: [ProgramGuide], app: Application) -> EventLoopFuture + func save(_ items: [ProgramGuide], app: Application) async } struct ProgramGuideRepository: ProgramGuideSaving { - func save(_ items: [ProgramGuide], app: Application) -> EventLoopFuture { - // 与えられたProgramGuideからProgramとPersonalitiesをDBに保存し、リレーションを貼る - let res = items.map { item -> EventLoopFuture in - // programを取得し、存在する場合は更新する - let program = upsertProgram(item.program, app.db) - let personalities = item.personalities.map { upsertPersonality($0, app.db) } - // 両方の成功時にリレーションを貼る - return personalities.map { - $0.flatMap { personality -> EventLoopFuture in - program.flatMap { program -> EventLoopFuture in - program.$personalities.isAttached(to: personality, on: app.db).flatMap { - isAttach -> EventLoopFuture in - if !isAttach { - return program.$personalities.attach(personality, on: app.db) - } - return app.eventLoopGroup.future() - } + func save(_ items: [ProgramGuide], app: Application) async { + for programGuide in items { + do { + let insertedProgram = try await upsertProgram(programGuide.program, app.db) + var insertedPersonalities: [Personality] = [] + for personality in programGuide.personalities { + let p = try await upsertPersonality(personality, app.db) + insertedPersonalities.append(p) + } + for personality in insertedPersonalities { + let isAttached = try? await insertedProgram.$personalities.isAttached( + to: personality, on: app.db) + if isAttached == false { + try await insertedProgram.$personalities.attach(personality, on: app.db) } } - } - .flatten(on: app.eventLoopGroup.next()) - .flatMapError { error in + } catch { + print("error") print(error.localizedDescription) - return app.eventLoopGroup.future(error: error) } } - return res.flatten(on: app.eventLoopGroup.next()) } /// 開始時間と終了時間を基準に(unique)insert or updateを行う. - func upsertProgram(_ program: Program, _ db: Database) -> EventLoopFuture { - return - Program - .query(on: db) + func upsertProgram(_ program: Program, _ db: Database) async throws -> Program { + let targetProgram = try? await Program.query(on: db) .filter(\.$startDatetime == program.startDatetime) .filter(\.$endDatetime == program.endDatetime) .first() - .flatMap { dbProgram -> EventLoopFuture in - program.id = dbProgram?.id - // idを挿入するだけだとうまくupdateできなかったため、判定要素を上書きする - program._$id.exists = dbProgram?.id != nil - return program.save(on: db).transform(to: program) - } + + program.id = targetProgram?.id + // idを挿入するだけだとうまくupdateできなかったため、判定要素を上書きする + program._$id.exists = targetProgram != nil + + try await program.save(on: db) + return program } /// 名前を基準に(unique)insert or updateを行う. - func upsertPersonality(_ personality: Personality, _ db: Database) -> EventLoopFuture { - return - Personality + func upsertPersonality(_ personality: Personality, _ db: Database) async throws -> Personality { + let targetPersonality = + try? await Personality .query(on: db) .filter(\.$name == personality.name) .first() - .flatMap { dbPersonality -> EventLoopFuture in - personality.id = dbPersonality?.id - // idを挿入するだけだとうまくupdateできなかったため、判定要素を上書きする - personality._$id.exists = dbPersonality?.id != nil - return personality.save(on: db).transform(to: personality) - } + + personality.id = targetPersonality?.id + // idを挿入するだけだとうまくupdateできなかったため、判定要素を上書きする + personality._$id.exists = targetPersonality != nil + + try await personality.save(on: db) + return personality } } diff --git a/Sources/App/Services/DownloadAgqrProgramGuide.swift b/Sources/App/Services/DownloadAgqrProgramGuide.swift index 87403d5..70d52cd 100644 --- a/Sources/App/Services/DownloadAgqrProgramGuide.swift +++ b/Sources/App/Services/DownloadAgqrProgramGuide.swift @@ -4,13 +4,11 @@ struct DownloadAgqrProgramGuide { // swift-format-ignore static let AGQR_URL = "https://www.joqr.co.jp/qr/agdailyprogram/" - func fetchToday(app: Application) -> EventLoopFuture { - return app.client.get(URI(string: Self.AGQR_URL)).map { res -> Data? in - return res.body.flatMap { $0.getData(at: 0, length: $0.writerIndex) } - } + func fetchToday(app: Application) async -> Data? { + return await self.execute(app: app, url: Self.AGQR_URL) } - func fetchWeekly(app: Application) -> EventLoopFuture<[Data?]> { + func fetchWeekly(app: Application) async -> [Data?] { // query date format=yyyyMMdd let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -21,16 +19,30 @@ struct DownloadAgqrProgramGuide { return formatter.string(from: calculateDate) }.map { "\(Self.AGQR_URL)?date=\($0)" } - return urls.map { url -> EventLoopFuture in - app.client.get(URI(string: url)).map { res -> Data? in - return res.body.flatMap { $0.getData(at: 0, length: $0.writerIndex) } + var responses: [Data?] = [] + await withTaskGroup(of: Data?.self) { group in + for url in urls { + group.addTask { + return await self.execute(app: app, url: url) + } + } + + for await response in group { + responses.append(response) } - }.flatten(on: app.eventLoopGroup.next()) + } + return responses } - func execute(app: Application, url: String?) -> EventLoopFuture { - return app.client.get(URI(string: url ?? Self.AGQR_URL)).map { res in - return res.body.flatMap { $0.getData(at: 0, length: $0.writerIndex) } + func execute(app: Application, url: String) async -> Data? { + do { + let response = try await app.client.get(URI(string: url)).get() + return response.body.flatMap { buffer in + buffer.getData(at: 0, length: buffer.writerIndex) + } + } catch { + print(error.localizedDescription) + return nil } } } diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index a37b1c5..c2da4b1 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -1,24 +1,22 @@ import Fluent -import FluentMySQLDriver +import FluentPostgresDriver import Leaf -import Queues -import QueuesRedisDriver import Vapor // configures your application public func configure(_ app: Application) throws { // uncomment to serve files from /Public folder // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) - app.databases.use( - .mysql( + .postgres( hostname: Environment.get("DATABASE_HOST") ?? "localhost", - port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? MySQLConfiguration.ianaPortNumber, + port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? PostgresConfiguration.ianaPortNumber, username: Environment.get("DATABASE_USERNAME") ?? "vapor_username", password: Environment.get("DATABASE_PASSWORD") ?? "vapor_password", - database: Environment.get("DATABASE_NAME") ?? "vapor_database", - tlsConfiguration: .forClient(certificateVerification: .none) - ), as: .mysql) + database: Environment.get("DATABASE_NAME") ?? "vapor_database" + ), + as: .psql + ) app.migrations.add(CreateProgram()) app.migrations.add(CreatePersonality()) @@ -26,30 +24,28 @@ public func configure(_ app: Application) throws { app.views.use(.leaf) + let pgParser = DailyProgramGuideParser() + let pgRepository = ProgramGuideRepository() app.commands.use( - ScrapingAgqr(parser: DailyProgramGuideParser(), repository: ProgramGuideRepository()), as: "scraping") - - try app.queues.use(.redis(url: Environment.get("REDIS_URL") ?? "redis://127.0.0.1:6379")) - - app.queues.schedule( - ImportProgramGuideJob(parser: DailyProgramGuideParser(), repository: ProgramGuideRepository()) + ScrapingAgqr(parser: pgParser, repository: pgRepository), as: "scraping") + app.commands.use( + ImportWeeklyPGCommand(parser: pgParser, repository: pgRepository), as: "import:weekly" ) - .daily() - .at(7, 0) // 07:00 am - - // try app.queues.startInProcessJobs(on: .default) - try app.queues.startScheduledJobs() // register middleware + app.middleware.use(newCORSMiddleware()) + + // register routes + try routes(app) +} + +func newCORSMiddleware() -> CORSMiddleware { let corsConfiguration = CORSMiddleware.Configuration( allowedOrigin: .all, allowedMethods: [.GET, .OPTIONS, .HEAD], allowedHeaders: [ - .accept, .contentType, .origin, .xRequestedWith, .userAgent, .accessControlAllowOrigin + .accept, .contentType, .origin, .xRequestedWith, .userAgent, .accessControlAllowOrigin, ] ) - app.middleware.use(CORSMiddleware(configuration: corsConfiguration)) - - // register routes - try routes(app) + return CORSMiddleware(configuration: corsConfiguration) } diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index f76ac49..c265835 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -3,11 +3,11 @@ import Vapor func routes(_ app: Application) throws { app.get { req in - return req.view.render("index", ["title": "Hello Vapor!"]) + return req.view.render("redoc-static.html") } - app.get("hello") { _ -> String in - return "Hello, world!" + app.get("health") { _ -> Response in + return Response(status: .ok) } try app.group("api") { builder in diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift index 1d4a8d0..11597a8 100644 --- a/Tests/AppTests/AppTests.swift +++ b/Tests/AppTests/AppTests.swift @@ -2,10 +2,9 @@ import XCTVapor final class AppTests: ControllerBaseTestCase { - func testHelloWorld() throws { - try app.test(.GET, "hello", afterResponse: { res in + func testHealth() throws { + try app.test(.GET, "health", afterResponse: { res in XCTAssertEqual(res.status, .ok) - XCTAssertEqual(res.body.string, "Hello, world!") }) } } diff --git a/docker-compose.yml b/compose.yaml similarity index 78% rename from docker-compose.yml rename to compose.yaml index 03070fa..6034180 100644 --- a/docker-compose.yml +++ b/compose.yaml @@ -14,8 +14,6 @@ # Run migrations: docker-compose run migrate # Stop all: docker-compose down (add -v to wipe db) # -version: '3.7' - volumes: db_data: @@ -40,6 +38,7 @@ services: command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] dev: image: agqr-program-guide:latest + platform: linux/amd64 build: context: . target: develop @@ -49,6 +48,7 @@ services: - db commands: image: agqr-program-guide:latest + platform: linux/amd64 build: context: . environment: @@ -57,19 +57,18 @@ services: - db command: ["--help"] db: - image: mysql:8.0 + image: postgres:15.5 volumes: - - db_data:/var/lib/mysql - - ./docker/mysql:/docker-entrypoint-initdb.d + - db_data:/var/lib/postgresql + - ./docker/db:/docker-entrypoint-initdb.d environment: - MYSQL_USER: vapor_username - MYSQL_PASSWORD: vapor_password - MYSQL_RANDOM_ROOT_PASSWORD: 'yes' + POSTGRES_USER: vapor_username + POSTGRES_PASSWORD: vapor_password ports: - - '3306:3306' - redis: - image: redis:latest - ports: - - '6379:6379' - volumes: - - ./data/redis:/data + - '5432:5432' + healthcheck: + test: ["CMD", "pg_isready", "-U", "vapor_username"] + interval: 10s + timeout: 30s + retries: 5 + start_period: 30s diff --git a/docker/db/01_create_databases.sql b/docker/db/01_create_databases.sql new file mode 100755 index 0000000..49f3ccc --- /dev/null +++ b/docker/db/01_create_databases.sql @@ -0,0 +1,4 @@ +CREATE DATABASE agqr_program_guide; +CREATE DATABASE test_agqr_program_guide; +GRANT ALL PRIVILEGES ON DATABASE agqr_program_guide TO vapor_username; +GRANT ALL PRIVILEGES ON DATABASE test_agqr_program_guide TO vapor_username; diff --git a/docker/mysql/01_create_databases.sql b/docker/mysql/01_create_databases.sql deleted file mode 100755 index 45a52b8..0000000 --- a/docker/mysql/01_create_databases.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE DATABASE IF NOT EXISTS `agqr_program_guide`; -GRANT ALL ON `agqr_program_guide`.* TO 'vapor_username'@'%'; -CREATE DATABASE IF NOT EXISTS `test_agqr_program_guide`; -GRANT ALL ON `test_agqr_program_guide`.* TO 'vapor_username'@'%'; diff --git a/reference/agqr-radio-program-guide-api.v2.yaml b/reference/agqr-radio-program-guide-api.v2.yaml index a1ca023..6a236ba 100644 --- a/reference/agqr-radio-program-guide-api.v2.yaml +++ b/reference/agqr-radio-program-guide-api.v2.yaml @@ -2,88 +2,26 @@ openapi: 3.0.0 info: title: agqr-radio-program-guide-api version: '1.0' - contact: - name: sun-yryr(t_minagawa) - url: 'https://twitter.com/taittide' - email: taittide@gmail.com + description: |- + **[非公式] 超A&G+ 番組表 API** + データ更新頻度: 1日ごと(JST 07:00 ごろ) + + 何かバグ等ありましたら、Issue を立てていただくか下記の連絡先までご連絡ください。 + Twitter(X): [@sun_yryr](https://twitter.com/sun_yryr) + GitHub: https://github.com/sun-yryr/agqr-program-guide servers: + - url: 'https://agqr.sun-yryr.com/api' + description: production - url: 'http://localhost:8080/api' description: local - - url: 'https://agqr.sun-yryr.com/api' - description: prod +tags: + - name: v1 + description: 'v1 API' paths: - /v2/programs/weekly: - get: - summary: '[未実装] 週間番組表を取得' - tags: [] - responses: - '200': - description: OK(検索結果がない場合は空配列になります) - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/program' - operationId: get-v2-programs-weekly - description: 条件に合った番組情報を返却する。 - parameters: - - schema: - type: string - in: query - name: q - description: '検索ワード(title,personality,infoの全てを対象)' - - schema: - type: string - example: 'audio,movie,repeat' - default: 'audio,movie' - in: query - name: include_program_type - description: |- - 検索結果に含める番組タイプ - [,]区切りで記述 - movie: 動画付きのみ - audio: 音声のみ - repeat: 再放送を含める - parameters: [] - /v2/programs/daily: - get: - summary: '[未実装] 日間番組表を取得' - tags: [] - responses: - '200': - description: OK(検索結果がない場合は空配列になります) - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/program' - operationId: get-v2-programs-daily - description: 条件に合った番組情報を返却する - parameters: - - schema: - type: string - in: query - name: q - description: '検索ワード(title,personality,infoの全てを対象)' - - schema: - type: string - example: 'audio,movie,repeat' - default: 'audio,movie' - in: query - name: include_program_type - description: |- - 検索結果に含める番組タイプ - [,]区切りで記述 - movie: 動画付きのみ - audio: 音声のみ - repeat: 再放送を含める - parameters: [] /all: get: summary: 週刊番組表を取得 - tags: [] + tags: [v1] responses: '200': description: OK @@ -95,12 +33,12 @@ paths: $ref: '#/components/schemas/old-program' operationId: get-all description: |- - 1週間分全ての番組情報を返却します - そのうち削除予定 + 1週間分全ての番組情報を返却する。 + 実行日の日本時間の0時から7日後の23時59分までの間の番組情報を返却する。 /today: get: summary: 日間番組表を取得 - tags: [] + tags: [v1] responses: '200': description: OK @@ -112,12 +50,12 @@ paths: $ref: '#/components/schemas/old-program' operationId: get-today description: |- - 本日放送予定の番組情報を返却します - そのうち削除予定 + 本日放送予定の番組情報を返却する。 + 本日とは、日本時間の0時から23時59分までの間のことを指す。 /now: get: summary: 現在の番組情報を返却 - tags: [] + tags: [v1] responses: '200': description: OK @@ -128,37 +66,7 @@ paths: items: $ref: '#/components/schemas/old-program' operationId: get-now - description: |- - 現在放送中の番組情報を返却します - そのうち削除予定 - /v2/programs/now: - get: - summary: '[未実装] 現在の番組情報を返却' - tags: [] - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/program' - '404': - description: 現在放送中の番組がありません - operationId: get-v2-programs-now - description: 現在放送中の番組情報を返却します - '/v2/programs/{programId}': - parameters: - - schema: - type: string - name: programId - in: path - required: true - get: - summary: '[未実装] 番組詳細を取得する' - tags: [] - responses: {} - operationId: get-v2-programs-programId - description: '' + description: 現在放送中の番組情報を返却する components: schemas: program: diff --git a/tools/Package.resolved b/tools/Package.resolved index d6a8022..abc38d1 100644 --- a/tools/Package.resolved +++ b/tools/Package.resolved @@ -6,16 +6,16 @@ "repositoryURL": "https://github.com/apple/swift-argument-parser.git", "state": { "branch": null, - "revision": "83b23d940471b313427da226196661856f6ba3e0", - "version": "0.4.4" + "revision": "e1465042f195f374b94f915ba8ca49de24300a0d", + "version": "1.0.2" } }, { "package": "swift-format", "repositoryURL": "https://github.com/apple/swift-format", "state": { - "branch": "swift-5.4-branch", - "revision": "9c15831b798d767c9af0927a931de5d557004936", + "branch": "swift-5.5-branch", + "revision": "f872223e16742fd97fabd319fbf4a939230cc796", "version": null } }, @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/apple/swift-syntax", "state": { "branch": null, - "revision": "2fff9fc25cdc059379b6bd309377cfab45d8520c", - "version": "0.50400.0" + "revision": "75e60475d9d8fd5bbc16a12e0eaa2cb01b0c322e", + "version": "0.50500.0" } } ] diff --git a/tools/Package.swift b/tools/Package.swift index 59a59d2..3cfdbc6 100644 --- a/tools/Package.swift +++ b/tools/Package.swift @@ -1,10 +1,10 @@ -// swift-tools-version:5.2 +// swift-tools-version:5.5 import PackageDescription let package = Package( name: "Tools", dependencies: [ - .package(url: "https://github.com/apple/swift-format", .branch("swift-5.4-branch")), + .package(url: "https://github.com/apple/swift-format", .branch("swift-5.5-branch")), // .package(url: "https://github.com/realm/SwiftLint.git", .upToNextMajor(from: "0.43.1")) ] )