Skip to content

Commit c5a4188

Browse files
authored
Merge pull request #21 from kiliankoe/sold-out-meals
Fetch sold out state for meals from RSS data
2 parents 3228f1a + d5b592d commit c5a4188

13 files changed

+920
-245
lines changed

.github/workflows/ci.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ on: [push]
44

55
jobs:
66
build_and_test:
7-
runs-on: macos-latest
7+
runs-on: macos-15 # go back to macos-latest once 15 becomes the default
88
steps:
9-
- uses: actions/checkout@v2
9+
- uses: actions/checkout@v4
1010
- name: Build
1111
run: swift build -v
1212
- name: Tests

Package.resolved

+9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
{
22
"object": {
33
"pins": [
4+
{
5+
"package": "FeedKit",
6+
"repositoryURL": "https://github.com/nmdias/FeedKit.git",
7+
"state": {
8+
"branch": null,
9+
"revision": "68493a33d862c33c9a9f67ec729b3b7df1b20ade",
10+
"version": "9.1.2"
11+
}
12+
},
413
{
514
"package": "HTMLString",
615
"repositoryURL": "https://github.com/alexaubry/HTMLString.git",

Package.swift

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ let package = Package(
1818
dependencies: [
1919
.package(url: "https://github.com/alexaubry/HTMLString.git", from: "5.0.0"),
2020
.package(url: "https://github.com/sharplet/Regex.git", from: "2.1.0"),
21+
.package(url: "https://github.com/nmdias/FeedKit.git", from: "9.1.2"),
2122
],
2223
targets: [
2324
.target(
2425
name: "EmealKit",
25-
dependencies: ["HTMLString", "Regex"]),
26+
dependencies: ["HTMLString", "Regex", "FeedKit"]),
2627
.testTarget(
2728
name: "EmealKitTests",
2829
dependencies: ["EmealKit"]),

Sources/EmealKit/Mensa/Meal.swift

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ public struct Meal: Identifiable, Equatable, Decodable {
1111
public var image: URL
1212
public var url: URL
1313

14+
public var isSoldOut: Bool?
15+
1416
private enum CodingKeys: String, CodingKey {
1517
case id
1618
case name

Sources/EmealKit/Mensa/MealFeed.swift

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import Foundation
2+
import FeedKit
3+
import os.log
4+
5+
extension Meal {
6+
public struct RSSMeal {
7+
public let title: String
8+
public let description: String
9+
public let guid: String
10+
public let link: String
11+
public let author: String
12+
13+
func matches(meal: Meal) -> Bool {
14+
return link.contains(String(meal.id))
15+
}
16+
17+
var isSoldOut: Bool {
18+
title.lowercased().contains("ausverkauft")
19+
}
20+
}
21+
22+
public static func rssData() async throws -> [RSSMeal] {
23+
let feedURL = URL(string: "https://www.studentenwerk-dresden.de/feeds/speiseplan.rss")!
24+
let parser = FeedParser(URL: feedURL)
25+
return try await withCheckedThrowingContinuation { continuation in
26+
parser.parseAsync { result in
27+
switch result {
28+
case .success(let feed):
29+
guard (feed.rssFeed?.title?.contains("von heute") ?? false) else {
30+
Logger.emealKit.error("Wrong feed?")
31+
continuation.resume(returning: [])
32+
return
33+
}
34+
guard let items = feed.rssFeed?.items else {
35+
Logger.emealKit.error("No feed items found")
36+
continuation.resume(returning: [])
37+
return
38+
}
39+
let meals = items.compactMap { item -> RSSMeal? in
40+
guard let title = item.title,
41+
let description = item.description,
42+
let guid = item.guid?.value,
43+
let link = item.link,
44+
let author = item.author
45+
else {
46+
return nil
47+
}
48+
return RSSMeal(
49+
title: title,
50+
description: description,
51+
guid: guid,
52+
link: link,
53+
author: author
54+
)
55+
}
56+
continuation.resume(returning: meals)
57+
case .failure(let error):
58+
continuation.resume(throwing: error)
59+
}
60+
}
61+
}
62+
}
63+
}

Sources/EmealKit/Mensa/MensaAPI.swift

+34-120
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import Foundation
22
import os.log
33

4-
#if canImport(Combine)
5-
import Combine
6-
#endif
7-
84
internal extension URL {
95
enum Mensa {
106
static let baseUrl = URL(string: "https://api.studentenwerk-dresden.de/openmensa/v2/")!
@@ -15,138 +11,56 @@ internal extension URL {
1511
}
1612
}
1713

18-
extension URLSession {
19-
fileprivate func emealDataTask(with url: URL, completion: @escaping (Result<Data, EmealError>) -> Void) {
20-
let task = self.dataTask(with: url) { data, response, error in
21-
guard
22-
let data = data,
23-
error == nil
24-
else {
25-
if let urlError = error {
26-
completion(.failure(.other(urlError)))
27-
return
28-
}
29-
completion(.failure(.unknown))
30-
return
31-
}
32-
completion(.success(data))
33-
}
34-
task.resume()
35-
}
36-
}
37-
3814
// MARK: - Canteens
3915

4016
extension Canteen {
41-
public static func all(session: URLSession = .shared,
42-
completion: @escaping (Result<[Canteen], EmealError>) -> Void) {
43-
Logger.emealKit.debug("Creating data task for all canteens")
44-
session.emealDataTask(with: URL.Mensa.canteens) { result in
45-
switch result {
46-
case .failure(let error):
47-
Logger.emealKit.error("Failed to fetch canteen data: \(String(describing: error))")
48-
completion(.failure(error))
49-
case .success(let data):
50-
do {
51-
let canteens = try JSONDecoder().decode([Canteen].self, from: data)
52-
Logger.emealKit.debug("Successfully fetched \(canteens.count) canteens")
53-
completion(.success(canteens))
54-
} catch let error {
55-
Logger.emealKit.error("Failed to decode Canteen data: \(String(describing: error))")
56-
completion(.failure(.other(error)))
57-
}
58-
}
17+
public static func all(session: URLSessionProtocol = URLSession.shared) async throws(EmealError) -> [Canteen] {
18+
Logger.emealKit.debug("Fetching all canteens")
19+
do {
20+
let (data, _) = try await session.data(from: URL.Mensa.canteens)
21+
print(String(data: data, encoding: .utf8)!)
22+
let canteens = try JSONDecoder().decode([Canteen].self, from: data)
23+
Logger.emealKit.debug("Successfully fetched \(canteens.count) canteens")
24+
return canteens
25+
} catch (let error) {
26+
Logger.emealKit.error("Failed to fetch canteen data: \(String(describing: error))")
27+
throw .other(error)
5928
}
6029
}
61-
62-
@available(macOS 12.0, iOS 15.0, *)
63-
public static func all(session: URLSession = .shared) async throws -> [Canteen] {
64-
try await withCheckedThrowingContinuation { continuation in
65-
Self.all(session: session) { result in
66-
continuation.resume(with: result)
67-
}
68-
}
69-
}
70-
}
71-
72-
#if canImport(Combine)
73-
extension Canteen {
74-
public static func allPublisher(session: URLSession = .shared) -> AnyPublisher<[Canteen], EmealError> {
75-
session.dataTaskPublisher(for: URL.Mensa.canteens)
76-
.map { $0.data }
77-
.decode(type: [Canteen].self, decoder: JSONDecoder())
78-
.mapError { EmealError.other($0) }
79-
.receive(on: DispatchQueue.main)
80-
.eraseToAnyPublisher()
81-
}
8230
}
83-
#endif
8431

8532
// MARK: - Meals
8633

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

95-
public static func `for`(canteen: Int,
96-
on date: Date,
97-
session: URLSession = .shared,
98-
completion: @escaping (Result<[Meal], EmealError>) -> Void) {
99-
Logger.emealKit.debug("Creating data task for canteen \(canteen) on \(date)")
100-
session.emealDataTask(with: URL.Mensa.meals(canteen: canteen, date: date)) { result in
101-
switch result {
102-
case .failure(let error):
103-
Logger.emealKit.error("Failed to fetch meal data: \(String(describing: error))")
104-
completion(.failure(error))
105-
case .success(let data):
106-
do {
107-
let meals = try JSONDecoder().decode([Meal].self, from: data)
108-
Logger.emealKit.debug("Successfully fetched \(meals.count) meals")
109-
completion(.success(meals))
110-
} catch let error {
111-
Logger.emealKit.error("Failed to decode meal data: \(String(describing: error))")
112-
completion(.failure(.other(error)))
42+
do {
43+
let feedItems = try await Self.rssData()
44+
return meals.map { meal in
45+
var meal = meal
46+
let matchingItem = feedItems.first { $0.matches(meal: meal) }
47+
if let matchingItem {
48+
Logger.emealKit.debug("Found matching feeditem for \(meal.id)")
49+
meal.isSoldOut = matchingItem.isSoldOut
50+
}
51+
return meal
11352
}
53+
} catch (let error) {
54+
Logger.emealKit.log("Failed to fetch rss data, continuing without: \(String(describing: error))")
55+
return meals
11456
}
57+
} catch (let error) {
58+
Logger.emealKit.error("Failed to fetch meal data: \(String(describing: error))")
59+
throw .other(error)
11560
}
11661
}
11762

118-
@available(macOS 12.0, iOS 15.0, *)
119-
public static func `for`(canteen: CanteenId, on date: Date, session: URLSession = .shared) async throws -> [Meal] {
63+
public static func `for`(canteen: CanteenId, on date: Date, session: URLSessionProtocol = URLSession.shared) async throws(EmealError) -> [Meal] {
12064
try await Self.for(canteen: canteen.rawValue, on: date, session: session)
12165
}
122-
123-
@available(macOS 12.0, iOS 15.0, *)
124-
public static func `for`(canteen: Int, on date: Date, session: URLSession = .shared) async throws -> [Meal] {
125-
try await withCheckedThrowingContinuation { continuation in
126-
Self.for(canteen: canteen, on: date, session: session) { result in
127-
continuation.resume(with: result)
128-
}
129-
}
130-
}
131-
}
132-
133-
#if canImport(Combine)
134-
extension Meal {
135-
public static func publisherFor(canteen: Int,
136-
on date: Date,
137-
session: URLSession = .shared) -> AnyPublisher<[Meal], EmealError> {
138-
session.dataTaskPublisher(for: URL.Mensa.meals(canteen: canteen, date: date))
139-
.map { $0.data }
140-
.decode(type: [Meal].self, decoder: JSONDecoder())
141-
.mapError { EmealError.other($0) }
142-
.receive(on: DispatchQueue.main)
143-
.eraseToAnyPublisher()
144-
}
145-
146-
public static func publisherFor(canteen: CanteenId,
147-
on date: Date,
148-
session: URLSession = .shared) -> AnyPublisher<[Meal], EmealError> {
149-
Self.publisherFor(canteen: canteen.rawValue, on: date, session: session)
150-
}
15166
}
152-
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Foundation
2+
3+
public protocol URLSessionProtocol {
4+
func data(from url: URL) async throws -> (Data, URLResponse)
5+
}
6+
7+
extension URLSession: URLSessionProtocol {
8+
9+
}

Tests/APIValidationTests/CardserviceAPITests.swift

+12
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,28 @@ class CardserviceAPITests: XCTestCase {
5050
}
5151

5252
func testLoginSuccess() async throws {
53+
if ProcessInfo.processInfo.environment["CI"] != nil {
54+
print("Skipping test in CI.")
55+
return
56+
}
5357
_ = try await Cardservice.login(username: username, password: password)
5458
}
5559

5660
func testFetchCarddata() async throws {
61+
if ProcessInfo.processInfo.environment["CI"] != nil {
62+
print("Skipping test in CI.")
63+
return
64+
}
5765
let cardservice = try await Cardservice.login(username: username, password: password)
5866
let carddata = try await cardservice.carddata()
5967
XCTAssert(!carddata.isEmpty)
6068
}
6169

6270
func testFetchTransactions() async throws {
71+
if ProcessInfo.processInfo.environment["CI"] != nil {
72+
print("Skipping test in CI.")
73+
return
74+
}
6375
let cardservice = try await Cardservice.login(username: username, password: password)
6476
let oneWeekAgo = Date().addingTimeInterval(-1 * 60 * 60 * 24 * 7)
6577
_ = try await cardservice.transactions(begin: oneWeekAgo)

Tests/APIValidationTests/MenuAPITests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import EmealKit
33

44
@available(macOS 12.0, iOS 15.0, *)
55
class MenuAPITests: XCTestCase {
6-
static let expectedCanteenCount = 21
6+
static let expectedCanteenCount = 16
77

88
/// Tests expect one of the following canteens to have meals for the current day, otherwise they fail.
99
static let expectedOpenCanteens: [CanteenId] = [.alteMensa, .mensaSiedepunkt, .mensaReichenbachstraße]

Tests/EmealKitTests/CanteenTests.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import CoreLocation
55
class CanteenTests: XCTestCase {
66
@available(macOS 12.0, iOS 15.0, *)
77
func testMockFetchAndDecode() async throws {
8-
let canteens = try await Canteen.all(session: MockURLSession(mockData: .canteens))
9-
XCTAssertEqual(canteens.count, 21)
8+
let canteens = try await Canteen.all(session: MockURLSession(data: .canteens))
9+
XCTAssertEqual(canteens.count, 16)
1010
}
1111

1212
func testLocation() {

Tests/EmealKitTests/MealTests.swift

+21-5
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@ import EmealKit
44
class MealTests: XCTestCase {
55
@available(macOS 12.0, iOS 15.0, *)
66
func testMockFetchAndDecode() async throws {
7-
let meals = try await Meal.for(canteen: .alteMensa, on: Date(), session: MockURLSession(mockData: .meals))
8-
XCTAssertEqual(meals.count, 5)
7+
let meals = try await Meal.for(canteen: .alteMensa, on: Date(), session: MockURLSession(data: .meals))
8+
XCTAssertEqual(meals.count, 4)
99
}
1010

1111
func testPlaceholderImage() {
12-
let meal = Meal(id: 0, name: "", notes: [], prices: nil, category: "",
13-
image: URL(string: "https://static.studentenwerk-dresden.de/bilder/mensen/studentenwerk-dresden-lieber-mensen-gehen.jpg")!,
14-
url: URL(string: "q")!)
12+
let meal = Meal(
13+
id: 0,
14+
name: "",
15+
notes: [],
16+
prices: nil,
17+
category: "",
18+
image: URL(string: "https://static.studentenwerk-dresden.de/bilder/mensen/studentenwerk-dresden-lieber-mensen-gehen.jpg")!,
19+
url: URL(string: "q")!
20+
)
1521
XCTAssert(meal.imageIsPlaceholder)
1622
}
1723

@@ -78,5 +84,15 @@ class MealTests: XCTestCase {
7884
XCTAssertEqual(prices2.students, 1.0)
7985
XCTAssertEqual(prices2.employees, 1.0)
8086
}
87+
88+
func testFeedData() async throws {
89+
// Unfortunately we can't really test this with mock data since there's no way to inject anything into FeedKit.
90+
let feedItems = try await Meal.rssData()
91+
XCTAssertGreaterThan(feedItems.count, 0)
92+
}
93+
94+
func testSoldOut() async throws {
95+
// see above
96+
}
8197
}
8298

0 commit comments

Comments
 (0)