Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
targets:
$default:
builders:
json_serializable:
options:
# TechAPI의 필드는 모두 snake_case다 (SPEC §14 컨벤션).
field_rename: snake
# 중첩 객체를 직렬화할 때 toJson()을 명시적으로 호출한다.
explicit_to_json: true
# 데이터셋에는 큐레이션이 덜 된 레코드가 많아 어떤 필드든 누락될 수 있다.
# 알 수 없는 키가 들어와도 파싱을 실패시키지 않는다.
disallow_unrecognized_keys: false
create_to_json: true
41 changes: 41 additions & 0 deletions lib/core/failure.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/// 데이터 계층에서 발생할 수 있는 실패의 분류.
///
/// UI가 "무엇이 잘못됐는지"에 따라 다르게 반응할 수 있도록 원인을 나눈다.
/// 네트워크 문제는 재시도 버튼을, 없는 레코드는 빈 상태를 보여야 한다.
sealed class Failure implements Exception {
const Failure(this.message, {this.cause});

final String message;
final Object? cause;

@override
String toString() => '$runtimeType: $message';
}

/// 연결 실패·타임아웃 등 요청이 서버에 닿지 못한 경우.
class NetworkFailure extends Failure {
const NetworkFailure(super.message, {super.cause});
}

/// 해당 slug의 레코드가 없다 (HTTP 404).
///
/// TechAPI 데이터셋은 큐레이션 중이라 상위 목록에 있어도 상세가 없을 수 있다.
class NotFoundFailure extends Failure {
const NotFoundFailure(this.collection, this.slug)
: super('$collection/$slug 레코드를 찾을 수 없다');

final String collection;
final String slug;
}

/// 응답은 왔지만 JSON이 기대한 형태가 아니다.
class ParseFailure extends Failure {
const ParseFailure(super.message, {super.cause});
}

/// 위 어디에도 속하지 않는 서버 오류.
class ServerFailure extends Failure {
const ServerFailure(super.message, {this.statusCode, super.cause});

final int? statusCode;
}
68 changes: 68 additions & 0 deletions lib/core/network/tech_api_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import 'dart:convert';

import 'package:dio/dio.dart';

import '../failure.dart';
import 'tech_api_source.dart';

/// TechAPI에서 JSON을 가져오는 얇은 클라이언트.
///
/// 파싱은 하지 않는다. HTTP 결과를 [Failure]로 번역하는 것까지가 책임이다.
class TechApiClient {
TechApiClient({TechApiSource? source, Dio? dio})
: source = source ?? const DumpSource(),
_dio = dio ?? Dio() {
_dio.options
..connectTimeout = const Duration(seconds: 15)
..receiveTimeout = const Duration(seconds: 30)
// 상태 코드 판단은 아래에서 직접 한다.
// 람다를 괄호로 감싸지 않으면 뒤따르는 캐스케이드를 람다 본문이 삼킨다.
..validateStatus = ((_) => true)
..responseType = ResponseType.json;
}

final TechApiSource source;
final Dio _dio;

/// [uri]에서 JSON 객체를 받아온다.
///
/// [collection]과 [slug]는 404를 [NotFoundFailure]로 만들 때만 쓰인다.
Future<Map<String, dynamic>> getJson(
Uri uri, {
String? collection,
String? slug,
}) async {
final Response<dynamic> response;
try {
response = await _dio.getUri<dynamic>(uri);
} on DioException catch (e) {
throw NetworkFailure('$uri 요청에 실패했다', cause: e);
}

final status = response.statusCode ?? 0;
if (status == 404) {
throw NotFoundFailure(collection ?? uri.path, slug ?? '');
}
if (status < 200 || status >= 300) {
throw ServerFailure('$uri 가 $status 를 반환했다', statusCode: status);
}

final data = response.data;
if (data is Map<String, dynamic>) return data;

// GitHub Pages가 Content-Type을 text/plain으로 줄 때 dio는 문자열을 넘긴다.
if (data is String) {
final Object? decoded;
try {
decoded = jsonDecode(data);
} on FormatException catch (e) {
throw ParseFailure('$uri 응답이 올바른 JSON이 아니다', cause: e);
}
if (decoded is Map<String, dynamic>) return decoded;
}

throw ParseFailure('$uri 응답이 JSON 객체가 아니다 (${data.runtimeType})');
}

void close() => _dio.close();
}
69 changes: 69 additions & 0 deletions lib/core/network/tech_api_source.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/// TechAPI 데이터를 어디서 가져올지 결정하는 전략.
///
/// TechAPI는 두 가지 형태로 같은 데이터를 제공한다.
///
/// * **정적 덤프** — `GetTechAPI/TechEngine`의 `app/dump.py`가 실제 FastAPI
/// 엔드포인트를 인프로세스로 replay해 생성한 JSON 트리. GitHub Pages로
/// 서빙된다. 서버가 필요 없고 지금 유일하게 살아 있는 경로다.
/// * **REST API** — `api.techapi.dev`. 2026-08-07 기준 미배포(DNS 미해결).
///
/// 덤프가 replay로 만들어지기 때문에 **두 경로의 응답 스키마는 동일하다.**
/// 차이는 URL 조립 규칙 하나뿐이므로 그 부분만 여기서 흡수한다.
///
/// ```
/// REST GET /v1/smartphones/galaxy-s25
/// 덤프 GET /v1/smartphones/galaxy-s25/index.json
/// ```
abstract class TechApiSource {
const TechApiSource();

/// 단일 레코드 URI. [collection]은 복수형(`smartphones`, `cpus` …).
Uri detail(String collection, String slug);

/// 컬렉션 목록 URI.
Uri list(String collection);

/// API 버전 인덱스 — 컬렉션별 레코드 수를 담고 있다.
Uri index();
}

/// GitHub Pages 정적 덤프. v2의 기본 소스.
class DumpSource extends TechApiSource {
const DumpSource({this.baseUrl = defaultBaseUrl});

static const String defaultBaseUrl = 'https://gettechapi.github.io/TechAPI';

final String baseUrl;

@override
Uri detail(String collection, String slug) =>
Uri.parse('$baseUrl/v1/$collection/$slug/index.json');

@override
Uri list(String collection) => Uri.parse('$baseUrl/v1/$collection/index.json');

@override
Uri index() => Uri.parse('$baseUrl/v1/index.json');
}

/// `api.techapi.dev` 배포 후 전환할 소스.
///
/// 덤프와 달리 쿼리 파라미터(`?limit`, `?brand`, `/search`, `/compare`)를
/// 지원하지만, 그 기능은 실제 배포 이후에 붙인다.
class RestSource extends TechApiSource {
const RestSource({this.baseUrl = defaultBaseUrl});

static const String defaultBaseUrl = 'https://api.techapi.dev';

final String baseUrl;

@override
Uri detail(String collection, String slug) =>
Uri.parse('$baseUrl/v1/$collection/$slug');

@override
Uri list(String collection) => Uri.parse('$baseUrl/v1/$collection');

@override
Uri index() => Uri.parse('$baseUrl/v1');
}
58 changes: 58 additions & 0 deletions lib/core/result.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import 'failure.dart';

/// 성공 또는 [Failure] 중 하나.
///
/// 리포지토리는 예외를 던지지 않고 이 타입을 돌려준다. 호출부가 실패 처리를
/// 잊는 것을 컴파일 단계에서 막기 위해서다.
sealed class Result<T> {
const Result();

const factory Result.ok(T value) = Ok<T>;
const factory Result.err(Failure failure) = Err<T>;

bool get isOk => this is Ok<T>;
bool get isErr => this is Err<T>;

/// 성공이면 값, 실패면 null.
T? get valueOrNull => switch (this) {
Ok<T>(:final value) => value,
Err<T>() => null,
};

/// 실패면 [Failure], 성공이면 null.
Failure? get failureOrNull => switch (this) {
Ok<T>() => null,
Err<T>(:final failure) => failure,
};

/// 성공 값을 변환한다. 실패는 그대로 통과시킨다.
Result<R> map<R>(R Function(T value) transform) => switch (this) {
Ok<T>(:final value) => Ok<R>(transform(value)),
Err<T>(:final failure) => Err<R>(failure),
};

/// 두 갈래를 모두 처리해 하나의 값으로 접는다.
R fold<R>(R Function(T value) onOk, R Function(Failure failure) onErr) =>
switch (this) {
Ok<T>(:final value) => onOk(value),
Err<T>(:final failure) => onErr(failure),
};
}

final class Ok<T> extends Result<T> {
const Ok(this.value);

final T value;

@override
String toString() => 'Ok($value)';
}

final class Err<T> extends Result<T> {
const Err(this.failure);

final Failure failure;

@override
String toString() => 'Err($failure)';
}
38 changes: 38 additions & 0 deletions lib/data/dto/brand.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:freezed_annotation/freezed_annotation.dart';

part 'brand.freezed.dart';
part 'brand.g.dart';

/// 제조사.
///
/// 같은 구조가 두 자리에서 쓰이는데 채워지는 필드가 다르다.
///
/// * `/v1/brands/{slug}` 상세 — 모든 필드
/// * 다른 레코드에 임베드될 때 — `slug`/`name`/`url` 정도만.
/// SoC의 `manufacturer`는 `id`조차 없다.
///
/// 그래서 `slug`와 `name`을 제외한 전부가 nullable이다.
@freezed
abstract class Brand with _$Brand {
const factory Brand({
required String slug,
required String name,
int? id,

/// ISO 3166-1 alpha-2 (예: `KR`).
String? country,
int? foundedYear,
String? logoUrl,
String? website,

/// 설명은 언어별로 따로 온다. `?lang=` 파라미터가 아니라 별도 필드다.
String? descriptionEn,
String? descriptionKo,

/// API 내부 상대 경로 (예: `/v1/brands/samsung`).
String? url,
@Default(<String>[]) List<String> sourceUrls,
}) = _Brand;

factory Brand.fromJson(Map<String, dynamic> json) => _$BrandFromJson(json);
}
Loading
Loading