Skip to content

Commit

Permalink
Merge pull request #21 from kiliankoe/sold-out-meals
Browse files Browse the repository at this point in the history
Fetch sold out state for meals from RSS data
  • Loading branch information
kiliankoe authored Nov 15, 2024
2 parents 3228f1a + d5b592d commit c5a4188
Show file tree
Hide file tree
Showing 13 changed files with 920 additions and 245 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ on: [push]

jobs:
build_and_test:
runs-on: macos-latest
runs-on: macos-15 # go back to macos-latest once 15 becomes the default
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Build
run: swift build -v
- name: Tests
Expand Down
9 changes: 9 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
{
"object": {
"pins": [
{
"package": "FeedKit",
"repositoryURL": "https://github.com/nmdias/FeedKit.git",
"state": {
"branch": null,
"revision": "68493a33d862c33c9a9f67ec729b3b7df1b20ade",
"version": "9.1.2"
}
},
{
"package": "HTMLString",
"repositoryURL": "https://github.com/alexaubry/HTMLString.git",
Expand Down
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/alexaubry/HTMLString.git", from: "5.0.0"),
.package(url: "https://github.com/sharplet/Regex.git", from: "2.1.0"),
.package(url: "https://github.com/nmdias/FeedKit.git", from: "9.1.2"),
],
targets: [
.target(
name: "EmealKit",
dependencies: ["HTMLString", "Regex"]),
dependencies: ["HTMLString", "Regex", "FeedKit"]),
.testTarget(
name: "EmealKitTests",
dependencies: ["EmealKit"]),
Expand Down
2 changes: 2 additions & 0 deletions Sources/EmealKit/Mensa/Meal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public struct Meal: Identifiable, Equatable, Decodable {
public var image: URL
public var url: URL

public var isSoldOut: Bool?

private enum CodingKeys: String, CodingKey {
case id
case name
Expand Down
63 changes: 63 additions & 0 deletions Sources/EmealKit/Mensa/MealFeed.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation
import FeedKit
import os.log

extension Meal {
public struct RSSMeal {
public let title: String
public let description: String
public let guid: String
public let link: String
public let author: String

func matches(meal: Meal) -> Bool {
return link.contains(String(meal.id))
}

var isSoldOut: Bool {
title.lowercased().contains("ausverkauft")
}
}

public static func rssData() async throws -> [RSSMeal] {
let feedURL = URL(string: "https://www.studentenwerk-dresden.de/feeds/speiseplan.rss")!
let parser = FeedParser(URL: feedURL)
return try await withCheckedThrowingContinuation { continuation in
parser.parseAsync { result in
switch result {
case .success(let feed):
guard (feed.rssFeed?.title?.contains("von heute") ?? false) else {
Logger.emealKit.error("Wrong feed?")
continuation.resume(returning: [])
return
}
guard let items = feed.rssFeed?.items else {
Logger.emealKit.error("No feed items found")
continuation.resume(returning: [])
return
}
let meals = items.compactMap { item -> RSSMeal? in
guard let title = item.title,
let description = item.description,
let guid = item.guid?.value,
let link = item.link,
let author = item.author
else {
return nil
}
return RSSMeal(
title: title,
description: description,
guid: guid,
link: link,
author: author
)
}
continuation.resume(returning: meals)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
}
154 changes: 34 additions & 120 deletions Sources/EmealKit/Mensa/MensaAPI.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import Foundation
import os.log

#if canImport(Combine)
import Combine
#endif

internal extension URL {
enum Mensa {
static let baseUrl = URL(string: "https://api.studentenwerk-dresden.de/openmensa/v2/")!
Expand All @@ -15,138 +11,56 @@ internal extension URL {
}
}

extension URLSession {
fileprivate func emealDataTask(with url: URL, completion: @escaping (Result<Data, EmealError>) -> Void) {
let task = self.dataTask(with: url) { data, response, error in
guard
let data = data,
error == nil
else {
if let urlError = error {
completion(.failure(.other(urlError)))
return
}
completion(.failure(.unknown))
return
}
completion(.success(data))
}
task.resume()
}
}

// MARK: - Canteens

extension Canteen {
public static func all(session: URLSession = .shared,
completion: @escaping (Result<[Canteen], EmealError>) -> Void) {
Logger.emealKit.debug("Creating data task for all canteens")
session.emealDataTask(with: URL.Mensa.canteens) { result in
switch result {
case .failure(let error):
Logger.emealKit.error("Failed to fetch canteen data: \(String(describing: error))")
completion(.failure(error))
case .success(let data):
do {
let canteens = try JSONDecoder().decode([Canteen].self, from: data)
Logger.emealKit.debug("Successfully fetched \(canteens.count) canteens")
completion(.success(canteens))
} catch let error {
Logger.emealKit.error("Failed to decode Canteen data: \(String(describing: error))")
completion(.failure(.other(error)))
}
}
public static func all(session: URLSessionProtocol = URLSession.shared) async throws(EmealError) -> [Canteen] {
Logger.emealKit.debug("Fetching all canteens")
do {
let (data, _) = try await session.data(from: URL.Mensa.canteens)
print(String(data: data, encoding: .utf8)!)
let canteens = try JSONDecoder().decode([Canteen].self, from: data)
Logger.emealKit.debug("Successfully fetched \(canteens.count) canteens")
return canteens
} catch (let error) {
Logger.emealKit.error("Failed to fetch canteen data: \(String(describing: error))")
throw .other(error)
}
}

@available(macOS 12.0, iOS 15.0, *)
public static func all(session: URLSession = .shared) async throws -> [Canteen] {
try await withCheckedThrowingContinuation { continuation in
Self.all(session: session) { result in
continuation.resume(with: result)
}
}
}
}

#if canImport(Combine)
extension Canteen {
public static func allPublisher(session: URLSession = .shared) -> AnyPublisher<[Canteen], EmealError> {
session.dataTaskPublisher(for: URL.Mensa.canteens)
.map { $0.data }
.decode(type: [Canteen].self, decoder: JSONDecoder())
.mapError { EmealError.other($0) }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
#endif

// MARK: - Meals

extension Meal {
public static func `for`(canteen: CanteenId,
on date: Date,
session: URLSession = .shared,
completion: @escaping (Result<[Meal], EmealError>) -> Void) {
Self.for(canteen: canteen.rawValue, on: date, session: session, completion: completion)
}
public static func `for`(canteen: Int, on date: Date, session: URLSessionProtocol = URLSession.shared) async throws(EmealError) -> [Meal] {
Logger.emealKit.debug("Fetching meals for canteen \(canteen) on \(date)")
do {
let (data, _) = try await session.data(from: URL.Mensa.meals(canteen: canteen, date: date))
let meals = try JSONDecoder().decode([Meal].self, from: data)
Logger.emealKit.debug("Successfully fetched \(meals.count) meals")

public static func `for`(canteen: Int,
on date: Date,
session: URLSession = .shared,
completion: @escaping (Result<[Meal], EmealError>) -> Void) {
Logger.emealKit.debug("Creating data task for canteen \(canteen) on \(date)")
session.emealDataTask(with: URL.Mensa.meals(canteen: canteen, date: date)) { result in
switch result {
case .failure(let error):
Logger.emealKit.error("Failed to fetch meal data: \(String(describing: error))")
completion(.failure(error))
case .success(let data):
do {
let meals = try JSONDecoder().decode([Meal].self, from: data)
Logger.emealKit.debug("Successfully fetched \(meals.count) meals")
completion(.success(meals))
} catch let error {
Logger.emealKit.error("Failed to decode meal data: \(String(describing: error))")
completion(.failure(.other(error)))
do {
let feedItems = try await Self.rssData()
return meals.map { meal in
var meal = meal
let matchingItem = feedItems.first { $0.matches(meal: meal) }
if let matchingItem {
Logger.emealKit.debug("Found matching feeditem for \(meal.id)")
meal.isSoldOut = matchingItem.isSoldOut
}
return meal
}
} catch (let error) {
Logger.emealKit.log("Failed to fetch rss data, continuing without: \(String(describing: error))")
return meals
}
} catch (let error) {
Logger.emealKit.error("Failed to fetch meal data: \(String(describing: error))")
throw .other(error)
}
}

@available(macOS 12.0, iOS 15.0, *)
public static func `for`(canteen: CanteenId, on date: Date, session: URLSession = .shared) async throws -> [Meal] {
public static func `for`(canteen: CanteenId, on date: Date, session: URLSessionProtocol = URLSession.shared) async throws(EmealError) -> [Meal] {
try await Self.for(canteen: canteen.rawValue, on: date, session: session)
}

@available(macOS 12.0, iOS 15.0, *)
public static func `for`(canteen: Int, on date: Date, session: URLSession = .shared) async throws -> [Meal] {
try await withCheckedThrowingContinuation { continuation in
Self.for(canteen: canteen, on: date, session: session) { result in
continuation.resume(with: result)
}
}
}
}

#if canImport(Combine)
extension Meal {
public static func publisherFor(canteen: Int,
on date: Date,
session: URLSession = .shared) -> AnyPublisher<[Meal], EmealError> {
session.dataTaskPublisher(for: URL.Mensa.meals(canteen: canteen, date: date))
.map { $0.data }
.decode(type: [Meal].self, decoder: JSONDecoder())
.mapError { EmealError.other($0) }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}

public static func publisherFor(canteen: CanteenId,
on date: Date,
session: URLSession = .shared) -> AnyPublisher<[Meal], EmealError> {
Self.publisherFor(canteen: canteen.rawValue, on: date, session: session)
}
}
#endif
9 changes: 9 additions & 0 deletions Sources/EmealKit/URLSessionProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

public protocol URLSessionProtocol {
func data(from url: URL) async throws -> (Data, URLResponse)
}

extension URLSession: URLSessionProtocol {

}
12 changes: 12 additions & 0 deletions Tests/APIValidationTests/CardserviceAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,28 @@ class CardserviceAPITests: XCTestCase {
}

func testLoginSuccess() async throws {
if ProcessInfo.processInfo.environment["CI"] != nil {
print("Skipping test in CI.")
return
}
_ = try await Cardservice.login(username: username, password: password)
}

func testFetchCarddata() async throws {
if ProcessInfo.processInfo.environment["CI"] != nil {
print("Skipping test in CI.")
return
}
let cardservice = try await Cardservice.login(username: username, password: password)
let carddata = try await cardservice.carddata()
XCTAssert(!carddata.isEmpty)
}

func testFetchTransactions() async throws {
if ProcessInfo.processInfo.environment["CI"] != nil {
print("Skipping test in CI.")
return
}
let cardservice = try await Cardservice.login(username: username, password: password)
let oneWeekAgo = Date().addingTimeInterval(-1 * 60 * 60 * 24 * 7)
_ = try await cardservice.transactions(begin: oneWeekAgo)
Expand Down
2 changes: 1 addition & 1 deletion Tests/APIValidationTests/MenuAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import EmealKit

@available(macOS 12.0, iOS 15.0, *)
class MenuAPITests: XCTestCase {
static let expectedCanteenCount = 21
static let expectedCanteenCount = 16

/// Tests expect one of the following canteens to have meals for the current day, otherwise they fail.
static let expectedOpenCanteens: [CanteenId] = [.alteMensa, .mensaSiedepunkt, .mensaReichenbachstraße]
Expand Down
4 changes: 2 additions & 2 deletions Tests/EmealKitTests/CanteenTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import CoreLocation
class CanteenTests: XCTestCase {
@available(macOS 12.0, iOS 15.0, *)
func testMockFetchAndDecode() async throws {
let canteens = try await Canteen.all(session: MockURLSession(mockData: .canteens))
XCTAssertEqual(canteens.count, 21)
let canteens = try await Canteen.all(session: MockURLSession(data: .canteens))
XCTAssertEqual(canteens.count, 16)
}

func testLocation() {
Expand Down
26 changes: 21 additions & 5 deletions Tests/EmealKitTests/MealTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@ import EmealKit
class MealTests: XCTestCase {
@available(macOS 12.0, iOS 15.0, *)
func testMockFetchAndDecode() async throws {
let meals = try await Meal.for(canteen: .alteMensa, on: Date(), session: MockURLSession(mockData: .meals))
XCTAssertEqual(meals.count, 5)
let meals = try await Meal.for(canteen: .alteMensa, on: Date(), session: MockURLSession(data: .meals))
XCTAssertEqual(meals.count, 4)
}

func testPlaceholderImage() {
let meal = Meal(id: 0, name: "", notes: [], prices: nil, category: "",
image: URL(string: "https://static.studentenwerk-dresden.de/bilder/mensen/studentenwerk-dresden-lieber-mensen-gehen.jpg")!,
url: URL(string: "q")!)
let meal = Meal(
id: 0,
name: "",
notes: [],
prices: nil,
category: "",
image: URL(string: "https://static.studentenwerk-dresden.de/bilder/mensen/studentenwerk-dresden-lieber-mensen-gehen.jpg")!,
url: URL(string: "q")!
)
XCTAssert(meal.imageIsPlaceholder)
}

Expand Down Expand Up @@ -78,5 +84,15 @@ class MealTests: XCTestCase {
XCTAssertEqual(prices2.students, 1.0)
XCTAssertEqual(prices2.employees, 1.0)
}

func testFeedData() async throws {
// Unfortunately we can't really test this with mock data since there's no way to inject anything into FeedKit.
let feedItems = try await Meal.rssData()
XCTAssertGreaterThan(feedItems.count, 0)
}

func testSoldOut() async throws {
// see above
}
}

Loading

0 comments on commit c5a4188

Please sign in to comment.